@lhi/n8m 0.2.4 → 0.3.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.
@@ -215,12 +215,31 @@ export default class Fixture extends Command {
215
215
  return;
216
216
  }
217
217
  const fixtureManager = new FixtureManager();
218
- const fixturePath = path.join(process.cwd(), '.n8m', 'fixtures', `${resolvedId}.json`);
218
+ const defaultName = latest.status === 'success' ? 'happy-path' : 'error-case';
219
+ const { fixtureName } = await inquirer.prompt([{
220
+ type: 'input',
221
+ name: 'fixtureName',
222
+ message: 'Name this fixture (e.g. happy-path, missing-field, bad-auth):',
223
+ default: defaultName,
224
+ }]);
225
+ const { expectedOutcome } = await inquirer.prompt([{
226
+ type: 'select',
227
+ name: 'expectedOutcome',
228
+ message: 'Expected test outcome for this case:',
229
+ choices: [
230
+ { name: 'pass — execution should succeed', value: 'pass' },
231
+ { name: 'fail — execution should fail (testing error handling)', value: 'fail' },
232
+ ],
233
+ default: latest.status === 'success' ? 'pass' : 'fail',
234
+ }]);
235
+ const fixtureDir = path.join(process.cwd(), '.n8m', 'fixtures', resolvedId);
236
+ const safeName = fixtureName.replace(/[^a-z0-9_-]/gi, '-').replace(/-+/g, '-').toLowerCase();
237
+ const fixturePath = path.join(fixtureDir, `${safeName}.json`);
219
238
  if (existsSync(fixturePath)) {
220
239
  const { overwrite } = await inquirer.prompt([{
221
240
  type: 'confirm',
222
241
  name: 'overwrite',
223
- message: `Fixture already exists for workflow ${resolvedId}. Overwrite?`,
242
+ message: `Fixture "${safeName}" already exists. Overwrite?`,
224
243
  default: true,
225
244
  }]);
226
245
  if (!overwrite) {
@@ -228,11 +247,13 @@ export default class Fixture extends Command {
228
247
  return;
229
248
  }
230
249
  }
231
- await fixtureManager.save({
250
+ await fixtureManager.saveNamed({
232
251
  version: '1.0',
233
252
  capturedAt: new Date().toISOString(),
234
253
  workflowId: resolvedId,
235
254
  workflowName: resolvedName ?? resolvedId,
255
+ description: fixtureName,
256
+ expectedOutcome: expectedOutcome,
236
257
  workflow,
237
258
  execution: {
238
259
  id: fullExec.id,
@@ -245,14 +266,16 @@ export default class Fixture extends Command {
245
266
  },
246
267
  },
247
268
  },
248
- });
269
+ }, safeName);
249
270
  const nodeCount = Object.keys(fullExec.data?.resultData?.runData ?? {}).length;
250
- this.log(theme.success(`Fixture saved to .n8m/fixtures/${resolvedId}.json`));
271
+ this.log(theme.success(`Fixture saved to .n8m/fixtures/${resolvedId}/${safeName}.json`));
251
272
  this.log(theme.muted(` Workflow: ${resolvedName}`));
252
273
  this.log(theme.muted(` Execution: ${fullExec.status} · ${nodeCount} node(s) captured`));
274
+ this.log(theme.muted(` Expected outcome: ${expectedOutcome}`));
253
275
  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)`));
276
+ this.log(theme.muted(' To run this fixture:'));
277
+ this.log(theme.muted(` n8m test --fixture .n8m/fixtures/${resolvedId}/${safeName}.json`));
278
+ this.log(theme.muted(' To run all fixtures for this workflow:'));
279
+ this.log(theme.muted(` n8m test --fixture .n8m/fixtures/${resolvedId}`));
257
280
  }
258
281
  }
@@ -0,0 +1,19 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class Learn extends Command {
3
+ static args: {
4
+ workflow: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
5
+ };
6
+ static description: string;
7
+ static examples: string[];
8
+ static flags: {
9
+ dir: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
+ all: import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
+ github: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
+ 'github-path': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
13
+ token: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
14
+ };
15
+ run(): Promise<void>;
16
+ private runGitHubImport;
17
+ private collectMdFiles;
18
+ private processWorkflow;
19
+ }
@@ -0,0 +1,277 @@
1
+ import { Args, Command, Flags } from '@oclif/core';
2
+ import { theme } from '../utils/theme.js';
3
+ import path from 'node:path';
4
+ import fs from 'node:fs/promises';
5
+ import { existsSync } from 'node:fs';
6
+ async function listGitHubPatterns(owner, repo, dirPath, branch, token) {
7
+ const url = `https://api.github.com/repos/${owner}/${repo}/contents/${dirPath}${branch ? `?ref=${branch}` : ''}`;
8
+ const headers = { 'User-Agent': 'n8m-cli', Accept: 'application/vnd.github.v3+json' };
9
+ if (token)
10
+ headers['Authorization'] = `Bearer ${token}`;
11
+ const res = await fetch(url, { headers });
12
+ if (!res.ok) {
13
+ const body = await res.text();
14
+ throw new Error(`GitHub API error ${res.status}: ${body}`);
15
+ }
16
+ const entries = await res.json();
17
+ return entries;
18
+ }
19
+ async function fetchRaw(url, token) {
20
+ const headers = { 'User-Agent': 'n8m-cli' };
21
+ if (token)
22
+ headers['Authorization'] = `Bearer ${token}`;
23
+ const res = await fetch(url, { headers });
24
+ if (!res.ok)
25
+ throw new Error(`Failed to fetch ${url}: ${res.status}`);
26
+ return res.text();
27
+ }
28
+ async function findWorkflowFiles(rootDir) {
29
+ const choices = [];
30
+ async function scan(dir) {
31
+ let entries;
32
+ try {
33
+ entries = await fs.readdir(dir, { withFileTypes: true });
34
+ }
35
+ catch {
36
+ return;
37
+ }
38
+ for (const entry of entries) {
39
+ const fullPath = path.join(dir, entry.name);
40
+ if (entry.isDirectory()) {
41
+ await scan(fullPath);
42
+ }
43
+ else if (entry.name.endsWith('.json')) {
44
+ const rel = path.relative(rootDir, fullPath);
45
+ let label = rel;
46
+ try {
47
+ const raw = await fs.readFile(fullPath, 'utf-8');
48
+ const parsed = JSON.parse(raw);
49
+ if (parsed.name)
50
+ label = `${parsed.name} (${rel})`;
51
+ }
52
+ catch {
53
+ // use rel path as label
54
+ }
55
+ choices.push({ name: label, value: fullPath });
56
+ }
57
+ }
58
+ }
59
+ await scan(rootDir);
60
+ return choices;
61
+ }
62
+ export default class Learn extends Command {
63
+ static args = {
64
+ workflow: Args.string({
65
+ description: 'Path to workflow JSON file (omit for interactive menu)',
66
+ required: false,
67
+ }),
68
+ };
69
+ static description = 'Extract reusable patterns from a validated workflow into the pattern library';
70
+ static examples = [
71
+ '<%= config.bin %> <%= command.id %>',
72
+ '<%= config.bin %> <%= command.id %> ./workflows/my-workflow/workflow.json',
73
+ '<%= config.bin %> <%= command.id %> --github owner/repo',
74
+ '<%= config.bin %> <%= command.id %> --github owner/repo --github-path patterns/google',
75
+ ];
76
+ static flags = {
77
+ dir: Flags.string({
78
+ char: 'd',
79
+ description: 'Directory to scan for workflows (default: ./workflows)',
80
+ }),
81
+ all: Flags.boolean({
82
+ description: 'Generate patterns for all workflows in the directory',
83
+ default: false,
84
+ }),
85
+ github: Flags.string({
86
+ description: 'Import patterns from a GitHub repo (format: owner/repo or owner/repo@branch)',
87
+ }),
88
+ 'github-path': Flags.string({
89
+ description: 'Path within the GitHub repo to import from (default: patterns)',
90
+ default: 'patterns',
91
+ }),
92
+ token: Flags.string({
93
+ description: 'GitHub personal access token (increases rate limit for public repos)',
94
+ env: 'GITHUB_TOKEN',
95
+ }),
96
+ };
97
+ async run() {
98
+ this.log(theme.brand());
99
+ const { args, flags } = await this.parse(Learn);
100
+ this.log(theme.header('PATTERN LEARNING'));
101
+ const patternsDir = path.join(process.cwd(), '.n8m', 'patterns');
102
+ await fs.mkdir(patternsDir, { recursive: true });
103
+ // GitHub import mode
104
+ if (flags.github) {
105
+ await this.runGitHubImport(flags.github, flags['github-path'], flags.token, patternsDir);
106
+ return;
107
+ }
108
+ // Local workflow → AI generation mode
109
+ const { AIService } = await import('../services/ai.service.js');
110
+ const aiService = AIService.getInstance();
111
+ const workflowsDir = flags.dir ?? path.join(process.cwd(), 'workflows');
112
+ let workflowPaths = [];
113
+ if (args.workflow) {
114
+ workflowPaths = [args.workflow];
115
+ }
116
+ else if (flags.all) {
117
+ this.log(theme.agent(`Scanning ${theme.secondary(workflowsDir)} for workflows...`));
118
+ const choices = await findWorkflowFiles(workflowsDir);
119
+ if (choices.length === 0) {
120
+ this.error(`No workflow JSON files found in ${workflowsDir}.`);
121
+ }
122
+ workflowPaths = choices.map(c => c.value);
123
+ this.log(theme.info(`Found ${workflowPaths.length} workflow(s).`));
124
+ }
125
+ else {
126
+ const { default: select } = await import('@inquirer/select');
127
+ this.log(theme.agent(`Scanning ${theme.secondary(workflowsDir)} for workflows...`));
128
+ const choices = await findWorkflowFiles(workflowsDir);
129
+ if (choices.length === 0) {
130
+ this.error(`No workflow JSON files found in ${workflowsDir}. Pass a file path directly or use --dir to specify another directory.`);
131
+ }
132
+ const selected = await select({
133
+ message: 'Select a workflow to learn from:',
134
+ choices,
135
+ pageSize: 15,
136
+ });
137
+ workflowPaths = [selected];
138
+ }
139
+ for (const workflowPath of workflowPaths) {
140
+ await this.processWorkflow(workflowPath, patternsDir, aiService);
141
+ }
142
+ }
143
+ async runGitHubImport(repoArg, dirPath, token, patternsDir) {
144
+ // Parse owner/repo@branch
145
+ const [repoPart, branch = ''] = repoArg.split('@');
146
+ const [owner, repo] = repoPart.split('/');
147
+ if (!owner || !repo) {
148
+ this.error('--github must be in the format owner/repo or owner/repo@branch');
149
+ }
150
+ this.log(theme.agent(`Fetching pattern list from ${theme.secondary(`github.com/${owner}/${repo}/${dirPath}`)}...`));
151
+ let entries;
152
+ try {
153
+ entries = await listGitHubPatterns(owner, repo, dirPath, branch, token);
154
+ }
155
+ catch (err) {
156
+ this.error(`Could not reach GitHub: ${err.message}`);
157
+ }
158
+ // Collect all .md files, recursing into subdirectories
159
+ const mdFiles = await this.collectMdFiles(entries, owner, repo, branch, token);
160
+ if (mdFiles.length === 0) {
161
+ this.log(theme.warn(`No .md pattern files found at ${dirPath} in ${owner}/${repo}.`));
162
+ return;
163
+ }
164
+ this.log(theme.info(`Found ${mdFiles.length} pattern(s).`));
165
+ const { checkbox } = await import('inquirer');
166
+ const selected = await checkbox({
167
+ message: 'Select patterns to import:',
168
+ choices: mdFiles.map(f => ({ name: f.path, value: f.path, checked: true })),
169
+ pageSize: 20,
170
+ });
171
+ if (selected.length === 0) {
172
+ this.log(theme.muted('Nothing selected.'));
173
+ return;
174
+ }
175
+ const toDownload = mdFiles.filter(f => selected.includes(f.path));
176
+ for (const file of toDownload) {
177
+ if (!file.download_url)
178
+ continue;
179
+ this.log(theme.agent(`Downloading ${theme.secondary(file.name)}...`));
180
+ const content = await fetchRaw(file.download_url, token);
181
+ const outPath = path.join(patternsDir, file.name);
182
+ if (existsSync(outPath)) {
183
+ const { default: select } = await import('@inquirer/select');
184
+ const action = await select({
185
+ message: `"${file.name}" already exists locally. Overwrite?`,
186
+ choices: [
187
+ { name: 'Overwrite', value: 'overwrite' },
188
+ { name: 'Skip', value: 'skip' },
189
+ ],
190
+ });
191
+ if (action === 'skip') {
192
+ this.log(theme.muted(`Skipped ${file.name}.`));
193
+ continue;
194
+ }
195
+ }
196
+ await fs.writeFile(outPath, content, 'utf-8');
197
+ this.log(theme.done(`Saved: ${theme.primary(outPath)}`));
198
+ }
199
+ this.log(theme.done(`Import complete. ${toDownload.length} pattern(s) added to ${theme.primary(patternsDir)}`));
200
+ }
201
+ async collectMdFiles(entries, owner, repo, branch, token) {
202
+ const result = [];
203
+ for (const entry of entries) {
204
+ if (entry.type === 'file' && entry.name.endsWith('.md')) {
205
+ result.push(entry);
206
+ }
207
+ else if (entry.type === 'dir') {
208
+ try {
209
+ const sub = await listGitHubPatterns(owner, repo, entry.path, branch, token);
210
+ const subFiles = await this.collectMdFiles(sub, owner, repo, branch, token);
211
+ result.push(...subFiles);
212
+ }
213
+ catch {
214
+ // skip unreadable subdirs
215
+ }
216
+ }
217
+ }
218
+ return result;
219
+ }
220
+ async processWorkflow(workflowPath, patternsDir, aiService) {
221
+ this.log(theme.divider(40));
222
+ this.log(`${theme.label('Workflow')} ${theme.value(workflowPath)}`);
223
+ let workflowJson;
224
+ try {
225
+ const raw = await fs.readFile(workflowPath, 'utf-8');
226
+ workflowJson = JSON.parse(raw);
227
+ }
228
+ catch {
229
+ this.log(theme.fail(`Could not read or parse ${workflowPath} — skipping.`));
230
+ return;
231
+ }
232
+ this.log(theme.agent(`Analyzing ${theme.secondary(workflowJson.name || 'workflow')}...`));
233
+ const { content, slug } = await aiService.generatePattern(workflowJson);
234
+ // Show preview
235
+ this.log(theme.subHeader('Generated Pattern Preview'));
236
+ const previewLines = content.split('\n').slice(0, 20);
237
+ previewLines.forEach((line) => this.log(theme.muted(line)));
238
+ if (content.split('\n').length > 20) {
239
+ this.log(theme.muted(` ... (${content.split('\n').length - 20} more lines)`));
240
+ }
241
+ this.log('');
242
+ const outPath = path.join(patternsDir, `${slug}.md`);
243
+ const alreadyExists = existsSync(outPath);
244
+ const { default: select } = await import('@inquirer/select');
245
+ const action = await select({
246
+ message: alreadyExists
247
+ ? `Pattern file "${slug}.md" already exists. What would you like to do?`
248
+ : `Save pattern as "${slug}.md"?`,
249
+ choices: alreadyExists
250
+ ? [
251
+ { name: 'Overwrite existing pattern', value: 'save' },
252
+ { name: 'Save with a new name', value: 'rename' },
253
+ { name: 'Skip', value: 'skip' },
254
+ ]
255
+ : [
256
+ { name: 'Save pattern', value: 'save' },
257
+ { name: 'Save with a different name', value: 'rename' },
258
+ { name: 'Skip', value: 'skip' },
259
+ ],
260
+ });
261
+ if (action === 'skip') {
262
+ this.log(theme.muted('Skipped.'));
263
+ return;
264
+ }
265
+ let finalPath = outPath;
266
+ if (action === 'rename') {
267
+ const { default: input } = await import('@inquirer/input');
268
+ const customName = await input({
269
+ message: 'Enter filename (without .md):',
270
+ default: slug,
271
+ });
272
+ finalPath = path.join(patternsDir, `${customName.replace(/\.md$/, '')}.md`);
273
+ }
274
+ await fs.writeFile(finalPath, content, 'utf-8');
275
+ this.log(theme.done(`Pattern saved: ${theme.primary(finalPath)}`));
276
+ }
277
+ }