@lhi/n8m 0.2.4 → 0.3.1
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 +176 -15
- package/dist/agentic/graph.d.ts +16 -4
- package/dist/agentic/nodes/architect.d.ts +2 -2
- package/dist/agentic/nodes/architect.js +5 -1
- package/dist/agentic/nodes/engineer.d.ts +6 -0
- package/dist/agentic/nodes/engineer.js +39 -5
- package/dist/commands/create.js +38 -1
- package/dist/commands/deploy.d.ts +2 -1
- package/dist/commands/deploy.js +119 -19
- package/dist/commands/fixture.js +31 -8
- package/dist/commands/learn.d.ts +19 -0
- package/dist/commands/learn.js +277 -0
- package/dist/commands/modify.js +210 -68
- package/dist/commands/test.d.ts +4 -0
- package/dist/commands/test.js +118 -14
- package/dist/services/ai.service.d.ts +33 -0
- package/dist/services/ai.service.js +337 -2
- package/dist/services/node-definitions.service.d.ts +8 -0
- package/dist/services/node-definitions.service.js +45 -0
- package/dist/utils/fixtureManager.d.ts +10 -0
- package/dist/utils/fixtureManager.js +43 -4
- package/dist/utils/multilinePrompt.js +33 -47
- package/dist/utils/n8nClient.js +60 -11
- package/docs/DEVELOPER_GUIDE.md +598 -0
- package/docs/N8N_NODE_REFERENCE.md +369 -0
- package/docs/patterns/bigquery-via-http.md +110 -0
- package/oclif.manifest.json +82 -3
- package/package.json +3 -1
- package/dist/fixture-schema.json +0 -162
- package/dist/resources/node-definitions-fallback.json +0 -390
- package/dist/resources/node-test-hints.json +0 -188
- package/dist/resources/workflow-test-fixtures.json +0 -42
package/dist/commands/deploy.js
CHANGED
|
@@ -1,14 +1,52 @@
|
|
|
1
1
|
import { Args, Command, Flags } from '@oclif/core';
|
|
2
2
|
import { theme } from '../utils/theme.js';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
async function findWorkflowFiles(rootDir) {
|
|
5
|
+
const fs = await import('node:fs/promises');
|
|
6
|
+
const choices = [];
|
|
7
|
+
async function scan(dir) {
|
|
8
|
+
let entries;
|
|
9
|
+
try {
|
|
10
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
for (const entry of entries) {
|
|
16
|
+
const fullPath = path.join(dir, entry.name);
|
|
17
|
+
if (entry.isDirectory()) {
|
|
18
|
+
await scan(fullPath);
|
|
19
|
+
}
|
|
20
|
+
else if (entry.name.endsWith('.json')) {
|
|
21
|
+
const rel = path.relative(rootDir, fullPath);
|
|
22
|
+
// Try to read workflow name from JSON
|
|
23
|
+
let label = rel;
|
|
24
|
+
try {
|
|
25
|
+
const raw = await fs.readFile(fullPath, 'utf-8');
|
|
26
|
+
const parsed = JSON.parse(raw);
|
|
27
|
+
if (parsed.name)
|
|
28
|
+
label = `${parsed.name} (${rel})`;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// use rel path as label
|
|
32
|
+
}
|
|
33
|
+
choices.push({ name: label, value: fullPath });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
await scan(rootDir);
|
|
38
|
+
return choices;
|
|
39
|
+
}
|
|
3
40
|
export default class Deploy extends Command {
|
|
4
41
|
static args = {
|
|
5
42
|
workflow: Args.string({
|
|
6
|
-
description: 'Path to
|
|
7
|
-
required:
|
|
43
|
+
description: 'Path to workflow JSON file (omit for interactive menu)',
|
|
44
|
+
required: false,
|
|
8
45
|
}),
|
|
9
46
|
};
|
|
10
47
|
static description = 'Push workflows to n8n instance via API';
|
|
11
48
|
static examples = [
|
|
49
|
+
'<%= config.bin %> <%= command.id %>',
|
|
12
50
|
'<%= config.bin %> <%= command.id %> ./workflows/slack-notifier.json',
|
|
13
51
|
];
|
|
14
52
|
static flags = {
|
|
@@ -22,27 +60,43 @@ export default class Deploy extends Command {
|
|
|
22
60
|
default: false,
|
|
23
61
|
description: 'Activate workflow after deployment',
|
|
24
62
|
}),
|
|
63
|
+
dir: Flags.string({
|
|
64
|
+
char: 'd',
|
|
65
|
+
description: 'Directory to scan for workflows (default: ./workflows)',
|
|
66
|
+
}),
|
|
25
67
|
};
|
|
26
68
|
async run() {
|
|
27
69
|
this.log(theme.brand());
|
|
28
70
|
const { args, flags } = await this.parse(Deploy);
|
|
29
71
|
this.log(theme.header('WORKFLOW DEPLOYMENT'));
|
|
30
|
-
this.log(theme.subHeader('Context Analysis'));
|
|
31
|
-
this.log(`${theme.label('Workflow')} ${theme.value(args.workflow)}`);
|
|
32
|
-
this.log(`${theme.label('Instance')} ${theme.value(flags.instance)}`);
|
|
33
|
-
this.log(`${theme.label('Auto-Activate')} ${theme.value(flags.activate)}`);
|
|
34
|
-
this.log(theme.divider(40));
|
|
35
72
|
try {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
73
|
+
let workflowPath = args.workflow;
|
|
74
|
+
if (!workflowPath) {
|
|
75
|
+
const { default: select } = await import('@inquirer/select');
|
|
76
|
+
const workflowsDir = flags.dir ?? path.join(process.cwd(), 'workflows');
|
|
77
|
+
this.log(theme.agent(`Scanning ${theme.secondary(workflowsDir)} for workflows...`));
|
|
78
|
+
const choices = await findWorkflowFiles(workflowsDir);
|
|
79
|
+
if (choices.length === 0) {
|
|
80
|
+
this.error(`No workflow JSON files found in ${workflowsDir}. Pass a file path directly or use --dir to specify another directory.`);
|
|
81
|
+
}
|
|
82
|
+
workflowPath = await select({
|
|
83
|
+
message: 'Select a workflow to deploy:',
|
|
84
|
+
choices,
|
|
85
|
+
pageSize: 15,
|
|
86
|
+
});
|
|
42
87
|
}
|
|
43
|
-
|
|
44
|
-
|
|
88
|
+
const resolvedPath = workflowPath;
|
|
89
|
+
this.log(theme.subHeader('Context Analysis'));
|
|
90
|
+
this.log(`${theme.label('Workflow')} ${theme.value(resolvedPath)}`);
|
|
91
|
+
this.log(`${theme.label('Instance')} ${theme.value(flags.instance)}`);
|
|
92
|
+
this.log(`${theme.label('Auto-Activate')} ${theme.value(flags.activate)}`);
|
|
93
|
+
this.log(theme.divider(40));
|
|
94
|
+
if (!resolvedPath.endsWith('.json')) {
|
|
95
|
+
throw new Error('Selected file must be a .json workflow file.');
|
|
45
96
|
}
|
|
97
|
+
const fs = await import('node:fs/promises');
|
|
98
|
+
const content = await fs.readFile(resolvedPath, 'utf-8');
|
|
99
|
+
const workflowData = JSON.parse(content);
|
|
46
100
|
this.log(theme.info('Authenticating...'));
|
|
47
101
|
const { ConfigManager } = await import('../utils/config.js');
|
|
48
102
|
const config = await ConfigManager.load();
|
|
@@ -54,12 +108,58 @@ export default class Deploy extends Command {
|
|
|
54
108
|
const { N8nClient } = await import('../utils/n8nClient.js');
|
|
55
109
|
const client = new N8nClient({ apiUrl: n8nUrl, apiKey: n8nKey });
|
|
56
110
|
this.log(theme.agent(`Transmitting bytecode to ${theme.secondary(n8nUrl)}`));
|
|
57
|
-
const
|
|
58
|
-
|
|
111
|
+
const saveIdToFile = async (id) => {
|
|
112
|
+
if (workflowData.id !== id) {
|
|
113
|
+
workflowData.id = id;
|
|
114
|
+
await fs.writeFile(resolvedPath, JSON.stringify(workflowData, null, 2), 'utf-8');
|
|
115
|
+
this.log(theme.muted(` Local file updated with ID: ${id}`));
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
let deployedId;
|
|
119
|
+
if (workflowData.id) {
|
|
120
|
+
let existsRemotely = false;
|
|
121
|
+
try {
|
|
122
|
+
await client.getWorkflow(workflowData.id);
|
|
123
|
+
existsRemotely = true;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// not found — will create
|
|
127
|
+
}
|
|
128
|
+
if (existsRemotely) {
|
|
129
|
+
const { default: select } = await import('@inquirer/select');
|
|
130
|
+
const action = await select({
|
|
131
|
+
message: `Workflow "${workflowData.name}" already exists in n8n (ID: ${workflowData.id}). What would you like to do?`,
|
|
132
|
+
choices: [
|
|
133
|
+
{ name: 'Update existing workflow', value: 'update' },
|
|
134
|
+
{ name: 'Create as new workflow', value: 'create' },
|
|
135
|
+
],
|
|
136
|
+
});
|
|
137
|
+
if (action === 'update') {
|
|
138
|
+
await client.updateWorkflow(workflowData.id, workflowData);
|
|
139
|
+
deployedId = workflowData.id;
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
const result = await client.createWorkflow(workflowData.name || 'n8m-deployment', workflowData);
|
|
143
|
+
deployedId = result.id;
|
|
144
|
+
await saveIdToFile(deployedId);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
const result = await client.createWorkflow(workflowData.name || 'n8m-deployment', workflowData);
|
|
149
|
+
deployedId = result.id;
|
|
150
|
+
await saveIdToFile(deployedId);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
const result = await client.createWorkflow(workflowData.name || 'n8m-deployment', workflowData);
|
|
155
|
+
deployedId = result.id;
|
|
156
|
+
await saveIdToFile(deployedId);
|
|
157
|
+
}
|
|
158
|
+
if (flags.activate && deployedId) {
|
|
59
159
|
this.log(theme.warn('Activation request queued.'));
|
|
60
160
|
}
|
|
61
|
-
this.log(theme.done(`Deployment Successful. [ID: ${theme.primary(
|
|
62
|
-
this.log(`${theme.label('Public Link')} ${theme.secondary(client.getWorkflowLink(
|
|
161
|
+
this.log(theme.done(`Deployment Successful. [ID: ${theme.primary(deployedId)}]`));
|
|
162
|
+
this.log(`${theme.label('Public Link')} ${theme.secondary(client.getWorkflowLink(deployedId))}`);
|
|
63
163
|
}
|
|
64
164
|
catch (error) {
|
|
65
165
|
this.error(`Operation aborted: ${error.message}`);
|
package/dist/commands/fixture.js
CHANGED
|
@@ -215,12 +215,31 @@ export default class Fixture extends Command {
|
|
|
215
215
|
return;
|
|
216
216
|
}
|
|
217
217
|
const fixtureManager = new FixtureManager();
|
|
218
|
-
const
|
|
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
|
|
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.
|
|
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
|
|
255
|
-
this.log(theme.muted(` n8m test --fixture .n8m/fixtures/${resolvedId}.json`));
|
|
256
|
-
this.log(theme.muted(
|
|
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
|
+
}
|