@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.
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 +3 -11
  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 +162 -95
  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
@@ -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
- // Check for interrupt/pause
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 { action } = await inquirer.prompt([{
128
- type: 'list',
129
- name: 'action',
130
- message: 'How would you like to proceed with the Blueprint?',
131
- choices: [
132
- { name: 'Approve and Generate Workflow', value: 'approve' },
133
- { name: 'Provide Feedback / Refine Strategy', value: 'feedback' },
134
- { name: 'Exit and Resume Later', value: 'exit' }
135
- ]
136
- }]);
137
- if (action === 'approve') {
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 (nodeName === 'qa') {
150
- const status = stateUpdate.validationStatus;
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 if (action === 'feedback') {
159
- const { feedback } = await inquirer.prompt([{
160
- type: 'input',
161
- name: 'feedback',
162
- message: 'Enter your feedback/instructions:',
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
- this.log(theme.agent("Updating strategy with your feedback..."));
165
- // In a real implementation, we'd loop back to Architect or update the goal.
166
- // For now, let's update userFeedback and resume.
167
- // To actually RE-ARCHITECT, we might need to jump back.
168
- // LangGraph can handle this by updating state and using a conditional edge.
169
- await graph.updateState({ configurable: { thread_id: threadId } }, { userFeedback: feedback }, nextNode);
170
- // For now, just resume and let Engineer see the feedback.
171
- const stream = await graph.stream(null, { configurable: { thread_id: threadId } });
172
- for await (const event of stream) {
173
- const nodeName = Object.keys(event)[0];
174
- const stateUpdate = event[nodeName];
175
- if (nodeName === 'engineer') {
176
- this.log(theme.agent(`⚙️ Engineer: Workflow code generated/updated (Feedback incorporated).`));
177
- if (stateUpdate.workflowJson)
178
- lastWorkflowJson = stateUpdate.workflowJson;
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 (nodeName === 'qa') {
181
- if (stateUpdate.validationStatus === 'passed')
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
- // Handle other interrupts (like QA)
193
- const { resume } = await inquirer.prompt([{
206
+ else if (nextNode === 'qa') {
207
+ const { proceed } = await inquirer.prompt([{
194
208
  type: 'confirm',
195
- name: 'resume',
196
- message: `Review completed for ${nextNode}. Resume workflow execution?`,
197
- default: true
209
+ name: 'proceed',
210
+ message: 'Workflow generated! Ready to run QA tests?',
211
+ default: true,
198
212
  }]);
199
- if (resume) {
200
- this.log(theme.agent("Resuming..."));
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
  }
@@ -22,7 +22,7 @@ export default class Doc extends Command {
22
22
  }),
23
23
  };
24
24
  async run() {
25
- const { args, flags } = await this.parse(Doc);
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
+ }