@lhi/n8m 0.2.1 â 0.2.3
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 +3 -11
- 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 +162 -95
- 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/create.js
CHANGED
|
@@ -8,6 +8,8 @@ import { randomUUID } from 'node:crypto';
|
|
|
8
8
|
import { graph, resumeAgenticWorkflow } from '../agentic/graph.js';
|
|
9
9
|
import { promptMultiline } from '../utils/multilinePrompt.js';
|
|
10
10
|
import { DocService } from '../services/doc.service.js';
|
|
11
|
+
import { ConfigManager } from '../utils/config.js';
|
|
12
|
+
import { N8nClient } from '../utils/n8nClient.js';
|
|
11
13
|
export default class Create extends Command {
|
|
12
14
|
static args = {
|
|
13
15
|
description: Args.string({
|
|
@@ -76,7 +78,6 @@ export default class Create extends Command {
|
|
|
76
78
|
const threadId = randomUUID();
|
|
77
79
|
this.log(theme.info(`\nInitializing Agentic Workflow for: "${description}" (Session: ${threadId})`));
|
|
78
80
|
let lastWorkflowJson = null;
|
|
79
|
-
let lastSpec = null;
|
|
80
81
|
try {
|
|
81
82
|
const stream = await runAgenticWorkflowStream(description, threadId);
|
|
82
83
|
for await (const event of stream) {
|
|
@@ -86,7 +87,6 @@ export default class Create extends Command {
|
|
|
86
87
|
if (nodeName === 'architect') {
|
|
87
88
|
this.log(theme.agent(`đď¸ Architect: Blueprint designed.`));
|
|
88
89
|
if (stateUpdate.strategies && stateUpdate.strategies.length > 0) {
|
|
89
|
-
lastSpec = stateUpdate.strategies[0]; // Default to primary
|
|
90
90
|
this.log(theme.header('\nPROPOSED STRATEGIES:'));
|
|
91
91
|
stateUpdate.strategies.forEach((s, i) => {
|
|
92
92
|
this.log(`${i === 0 ? theme.success(' [Primary]') : theme.info(' [Alternative]')} ${theme.value(s.suggestedName)}`);
|
|
@@ -98,117 +98,150 @@ export default class Create extends Command {
|
|
|
98
98
|
});
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
|
-
else if (nodeName === 'engineer') {
|
|
102
|
-
this.log(theme.agent(`âď¸ Engineer: Workflow code generated/updated.`));
|
|
103
|
-
if (stateUpdate.workflowJson) {
|
|
104
|
-
lastWorkflowJson = stateUpdate.workflowJson;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
else if (nodeName === 'qa') {
|
|
108
|
-
const status = stateUpdate.validationStatus;
|
|
109
|
-
if (status === 'passed') {
|
|
110
|
-
this.log(theme.success(`đ§Ş QA: Validation Passed!`));
|
|
111
|
-
}
|
|
112
|
-
else {
|
|
113
|
-
this.log(theme.fail(`đ§Ş QA: Validation Failed.`));
|
|
114
|
-
if (stateUpdate.validationErrors && stateUpdate.validationErrors.length > 0) {
|
|
115
|
-
stateUpdate.validationErrors.forEach((e) => this.log(theme.error(` - ${e}`)));
|
|
116
|
-
}
|
|
117
|
-
this.log(theme.warn(` Looping back to Engineer for repairs...`));
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
101
|
}
|
|
121
|
-
//
|
|
102
|
+
// Handle interrupt/pause loop
|
|
122
103
|
let snapshot = await graph.getState({ configurable: { thread_id: threadId } });
|
|
123
104
|
while (snapshot.next.length > 0) {
|
|
124
105
|
const nextNode = snapshot.next[0];
|
|
125
|
-
this.log(theme.warn(`\nâ¸ď¸ Workflow Paused at step: ${nextNode}`));
|
|
126
106
|
if (nextNode === 'engineer') {
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
this.log(theme.agent("Approve! Proceeding to engineering..."));
|
|
139
|
-
await graph.updateState({ configurable: { thread_id: threadId } }, { userFeedback: undefined }, nextNode);
|
|
140
|
-
const stream = await graph.stream(null, { configurable: { thread_id: threadId } });
|
|
141
|
-
for await (const event of stream) {
|
|
142
|
-
const nodeName = Object.keys(event)[0];
|
|
143
|
-
const stateUpdate = event[nodeName];
|
|
144
|
-
if (nodeName === 'engineer') {
|
|
145
|
-
this.log(theme.agent(`âď¸ Engineer: Workflow code generated/updated.`));
|
|
146
|
-
if (stateUpdate.workflowJson)
|
|
147
|
-
lastWorkflowJson = stateUpdate.workflowJson;
|
|
107
|
+
const isRepair = (snapshot.values.validationErrors || []).length > 0;
|
|
108
|
+
if (isRepair) {
|
|
109
|
+
// Repair iteration â auto-continue without asking the user
|
|
110
|
+
const repairStream = await graph.stream(null, { configurable: { thread_id: threadId } });
|
|
111
|
+
for await (const event of repairStream) {
|
|
112
|
+
const n = Object.keys(event)[0];
|
|
113
|
+
const u = event[n];
|
|
114
|
+
if (n === 'engineer') {
|
|
115
|
+
this.log(theme.agent(`âď¸ Engineer: Applying fixes...`));
|
|
116
|
+
if (u.workflowJson)
|
|
117
|
+
lastWorkflowJson = u.workflowJson;
|
|
148
118
|
}
|
|
149
|
-
else if (
|
|
150
|
-
|
|
151
|
-
if (status === 'passed')
|
|
152
|
-
this.log(theme.success(`đ§Ş QA: Validation Passed!`));
|
|
153
|
-
else
|
|
154
|
-
this.log(theme.fail(`đ§Ş QA: Validation Failed.`));
|
|
119
|
+
else if (n === 'supervisor' && u.workflowJson) {
|
|
120
|
+
lastWorkflowJson = u.workflowJson;
|
|
155
121
|
}
|
|
156
122
|
}
|
|
157
123
|
}
|
|
158
|
-
else
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
124
|
+
else {
|
|
125
|
+
// Initial build â let user choose which strategy to use
|
|
126
|
+
const strategies = snapshot.values.strategies || [];
|
|
127
|
+
const spec = snapshot.values.spec;
|
|
128
|
+
const choices = [];
|
|
129
|
+
if (strategies.length > 0) {
|
|
130
|
+
strategies.forEach((s, i) => {
|
|
131
|
+
const tag = i === 0 ? 'Primary' : 'Alternative';
|
|
132
|
+
const nodes = s.nodes
|
|
133
|
+
?.map((n) => n.type?.split('.').pop())
|
|
134
|
+
.join(', ');
|
|
135
|
+
choices.push({
|
|
136
|
+
name: `[${tag}] ${s.suggestedName}${nodes ? ` ¡ ${nodes}` : ''}`,
|
|
137
|
+
value: { type: 'build', strategy: s },
|
|
138
|
+
short: s.suggestedName,
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
else if (spec) {
|
|
143
|
+
choices.push({
|
|
144
|
+
name: spec.suggestedName,
|
|
145
|
+
value: { type: 'build', strategy: spec },
|
|
146
|
+
short: spec.suggestedName,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
choices.push(new inquirer.Separator());
|
|
150
|
+
choices.push({
|
|
151
|
+
name: 'Add feedback before building',
|
|
152
|
+
value: { type: 'feedback' },
|
|
153
|
+
short: 'Add feedback',
|
|
154
|
+
});
|
|
155
|
+
choices.push({
|
|
156
|
+
name: 'Exit (save session to resume later)',
|
|
157
|
+
value: { type: 'exit' },
|
|
158
|
+
short: 'Exit',
|
|
159
|
+
});
|
|
160
|
+
const { choice } = await inquirer.prompt([{
|
|
161
|
+
type: 'list',
|
|
162
|
+
name: 'choice',
|
|
163
|
+
message: strategies.length > 1
|
|
164
|
+
? 'The Architect designed two approaches â which should the Engineer build?'
|
|
165
|
+
: 'Blueprint ready. How would you like to proceed?',
|
|
166
|
+
choices,
|
|
163
167
|
}]);
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
168
|
+
if (choice.type === 'exit') {
|
|
169
|
+
this.log(theme.info(`\nSession saved. Resume later with: n8m resume ${threadId}`));
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
let chosenSpec = choice.strategy ?? spec;
|
|
173
|
+
let stateUpdate = { spec: chosenSpec, userFeedback: undefined };
|
|
174
|
+
if (choice.type === 'feedback') {
|
|
175
|
+
const { feedback } = await inquirer.prompt([{
|
|
176
|
+
type: 'input',
|
|
177
|
+
name: 'feedback',
|
|
178
|
+
message: 'Describe your refinements (the Engineer will incorporate them):',
|
|
179
|
+
}]);
|
|
180
|
+
chosenSpec = strategies[0] ?? spec;
|
|
181
|
+
stateUpdate = { spec: chosenSpec, userFeedback: feedback };
|
|
182
|
+
this.log(theme.agent(`Feedback noted. Building "${chosenSpec?.suggestedName}" with your refinements...`));
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
this.log(theme.agent(`Building "${chosenSpec?.suggestedName}"...`));
|
|
186
|
+
}
|
|
187
|
+
await graph.updateState({ configurable: { thread_id: threadId } }, stateUpdate, nextNode);
|
|
188
|
+
const buildStream = await graph.stream(null, { configurable: { thread_id: threadId } });
|
|
189
|
+
for await (const event of buildStream) {
|
|
190
|
+
const n = Object.keys(event)[0];
|
|
191
|
+
const u = event[n];
|
|
192
|
+
if (n === 'engineer') {
|
|
193
|
+
this.log(theme.agent(`âď¸ Engineer: Building workflow...`));
|
|
194
|
+
if (u.workflowJson)
|
|
195
|
+
lastWorkflowJson = u.workflowJson;
|
|
196
|
+
}
|
|
197
|
+
else if (n === 'supervisor' && u.workflowJson) {
|
|
198
|
+
lastWorkflowJson = u.workflowJson;
|
|
179
199
|
}
|
|
180
|
-
else if (
|
|
181
|
-
|
|
182
|
-
this.log(theme.success(`đ§Ş QA: Validation Passed!`));
|
|
200
|
+
else if (n === 'reviewer' && u.validationStatus === 'failed') {
|
|
201
|
+
this.log(theme.warn(` Reviewer flagged issues â Engineer will revise...`));
|
|
183
202
|
}
|
|
184
203
|
}
|
|
185
204
|
}
|
|
186
|
-
else {
|
|
187
|
-
this.log(theme.info(`Session persisted. Resume later with: n8m resume ${threadId}`));
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
190
205
|
}
|
|
191
|
-
else {
|
|
192
|
-
|
|
193
|
-
const { resume } = await inquirer.prompt([{
|
|
206
|
+
else if (nextNode === 'qa') {
|
|
207
|
+
const { proceed } = await inquirer.prompt([{
|
|
194
208
|
type: 'confirm',
|
|
195
|
-
name: '
|
|
196
|
-
message:
|
|
197
|
-
default: true
|
|
209
|
+
name: 'proceed',
|
|
210
|
+
message: 'Workflow generated! Ready to run QA tests?',
|
|
211
|
+
default: true,
|
|
198
212
|
}]);
|
|
199
|
-
if (
|
|
200
|
-
this.log(theme.
|
|
201
|
-
const result = await resumeAgenticWorkflow(threadId);
|
|
202
|
-
if (result.validationStatus === 'passed') {
|
|
203
|
-
this.log(theme.success(`đ§Ş QA (Resumed): Validation Passed!`));
|
|
204
|
-
if (result.workflowJson)
|
|
205
|
-
lastWorkflowJson = result.workflowJson;
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
else {
|
|
209
|
-
this.log(theme.info(`Session persisted. Resume later with: n8m resume ${threadId}`));
|
|
213
|
+
if (!proceed) {
|
|
214
|
+
this.log(theme.info(`\nSession saved. Resume later with: n8m resume ${threadId}`));
|
|
210
215
|
return;
|
|
211
216
|
}
|
|
217
|
+
const qaStream = await graph.stream(null, { configurable: { thread_id: threadId } });
|
|
218
|
+
for await (const event of qaStream) {
|
|
219
|
+
const n = Object.keys(event)[0];
|
|
220
|
+
const u = event[n];
|
|
221
|
+
if (n === 'qa') {
|
|
222
|
+
if (u.validationStatus === 'passed') {
|
|
223
|
+
this.log(theme.success(`đ§Ş QA: Validation Passed!`));
|
|
224
|
+
if (u.workflowJson)
|
|
225
|
+
lastWorkflowJson = u.workflowJson;
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
this.log(theme.fail(`đ§Ş QA: Validation Failed.`));
|
|
229
|
+
if (u.validationErrors?.length) {
|
|
230
|
+
u.validationErrors.forEach(e => this.log(theme.error(` - ${e}`)));
|
|
231
|
+
}
|
|
232
|
+
this.log(theme.warn(` Looping back to Engineer for repairs...`));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
else if (n === 'supervisor' && u.workflowJson) {
|
|
236
|
+
lastWorkflowJson = u.workflowJson;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
// Unknown interrupt â auto-resume
|
|
242
|
+
const result = await resumeAgenticWorkflow(threadId);
|
|
243
|
+
if (result.workflowJson)
|
|
244
|
+
lastWorkflowJson = result.workflowJson;
|
|
212
245
|
}
|
|
213
246
|
snapshot = await graph.getState({ configurable: { thread_id: threadId } });
|
|
214
247
|
}
|
|
@@ -225,7 +258,6 @@ export default class Create extends Command {
|
|
|
225
258
|
const savedResources = [];
|
|
226
259
|
const docService = DocService.getInstance();
|
|
227
260
|
for (const workflow of workflows) {
|
|
228
|
-
const workflowName = workflow.name || (lastSpec && lastSpec.suggestedName) || 'generated-workflow';
|
|
229
261
|
const projectTitle = await docService.generateProjectTitle(workflow);
|
|
230
262
|
workflow.name = projectTitle; // Standardize name
|
|
231
263
|
const slug = docService.generateSlug(projectTitle);
|
|
@@ -243,6 +275,41 @@ export default class Create extends Command {
|
|
|
243
275
|
await fs.writeFile(path.join(targetDir, 'README.md'), fullDoc);
|
|
244
276
|
savedResources.push({ path: targetFile, name: projectTitle, original: workflow });
|
|
245
277
|
}
|
|
278
|
+
// 4. DEPLOY PROMPT
|
|
279
|
+
const deployConfig = await ConfigManager.load();
|
|
280
|
+
const n8nUrl = process.env.N8N_API_URL || deployConfig.n8nUrl;
|
|
281
|
+
const n8nKey = process.env.N8N_API_KEY || deployConfig.n8nKey;
|
|
282
|
+
if (n8nUrl && n8nKey) {
|
|
283
|
+
const { shouldDeploy } = await inquirer.prompt([{
|
|
284
|
+
type: 'confirm',
|
|
285
|
+
name: 'shouldDeploy',
|
|
286
|
+
message: 'Deploy validated workflow to n8n?',
|
|
287
|
+
default: true,
|
|
288
|
+
}]);
|
|
289
|
+
if (shouldDeploy) {
|
|
290
|
+
const { activate } = await inquirer.prompt([{
|
|
291
|
+
type: 'confirm',
|
|
292
|
+
name: 'activate',
|
|
293
|
+
message: 'Activate workflow after deployment?',
|
|
294
|
+
default: false,
|
|
295
|
+
}]);
|
|
296
|
+
const client = new N8nClient({ apiUrl: n8nUrl, apiKey: n8nKey });
|
|
297
|
+
for (const { name, original } of savedResources) {
|
|
298
|
+
try {
|
|
299
|
+
const result = await client.createWorkflow(name, original);
|
|
300
|
+
this.log(theme.done(`Deployed: ${name} [ID: ${result.id}]`));
|
|
301
|
+
this.log(`${theme.label('Link')} ${theme.secondary(client.getWorkflowLink(result.id))}`);
|
|
302
|
+
if (activate) {
|
|
303
|
+
await client.activateWorkflow(result.id);
|
|
304
|
+
this.log(theme.info('Workflow activated.'));
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
catch (err) {
|
|
308
|
+
this.log(theme.error(`Deploy failed for "${name}": ${err.message}`));
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
246
313
|
this.log(theme.done('Agentic Workflow Complete.'));
|
|
247
314
|
process.exit(0);
|
|
248
315
|
}
|
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
|
+
}
|