@lhi/n8m 0.3.3 → 1.0.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 +27 -9
- package/banner.txt +9 -0
- package/dist/agentic/graph.d.ts +11 -1
- package/dist/agentic/graph.js +2 -1
- package/dist/agentic/nodes/architect.js +3 -2
- package/dist/agentic/nodes/engineer.js +4 -2
- package/dist/agentic/state.d.ts +2 -0
- package/dist/agentic/state.js +4 -0
- package/dist/commands/create.js +15 -2
- package/dist/commands/deploy.d.ts +2 -0
- package/dist/commands/deploy.js +25 -8
- package/dist/commands/fixture.js +1 -0
- package/dist/commands/learn.js +1 -1
- package/dist/commands/modify.js +13 -1
- package/dist/commands/rollback.d.ts +31 -0
- package/dist/commands/rollback.js +201 -0
- package/dist/fixture-schema.json +162 -0
- package/dist/help.d.ts +6 -0
- package/dist/help.js +12 -0
- package/dist/resources/node-definitions-fallback.json +390 -0
- 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 +9 -2
- package/dist/services/ai.service.js +27 -6
- package/dist/services/git.service.d.ts +52 -0
- package/dist/services/git.service.js +110 -0
- package/dist/services/mcp.service.d.ts +1 -0
- package/dist/services/mcp.service.js +201 -0
- package/dist/services/node-definitions.service.js +1 -1
- package/dist/utils/config.js +3 -1
- package/dist/utils/n8nClient.d.ts +11 -0
- package/dist/utils/n8nClient.js +39 -0
- package/docs/.nojekyll +0 -0
- package/docs/CNAME +1 -0
- package/docs/DEVELOPER_GUIDE.md +12 -2
- package/docs/apple-touch-icon.png +0 -0
- package/docs/favicon-16x16.png +0 -0
- package/docs/favicon-192x192.png +0 -0
- package/docs/favicon-32x32.png +0 -0
- package/docs/favicon.svg +4 -0
- package/docs/index.html +1580 -0
- package/docs/social-card.html +237 -0
- package/docs/social-card.png +0 -0
- package/n8m-cover.png +0 -0
- package/n8m-logo-light.png +0 -0
- package/n8m-logo-mono.png +0 -0
- package/n8m-logo-v2.png +0 -0
- package/oclif.manifest.json +68 -1
- package/package.json +12 -1
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { Args, Command, Flags } from '@oclif/core';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import { theme } from '../utils/theme.js';
|
|
5
|
+
import { GitService, formatCommitChoice, diffWorkflowNodes } from '../services/git.service.js';
|
|
6
|
+
/**
|
|
7
|
+
* Build the interactive select choices from a list of git commits.
|
|
8
|
+
* The most-recent commit is labelled "(HEAD)" to make it easy to identify.
|
|
9
|
+
* Pure function — safe to unit test.
|
|
10
|
+
*/
|
|
11
|
+
export function buildRollbackChoices(commits) {
|
|
12
|
+
return commits.map((commit, index) => ({
|
|
13
|
+
name: `${formatCommitChoice(commit)}${index === 0 ? ' (HEAD)' : ''}`,
|
|
14
|
+
value: commit.hash,
|
|
15
|
+
}));
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Build a human-readable diff preview comparing the current on-disk version
|
|
19
|
+
* of a workflow against the version about to be restored.
|
|
20
|
+
* Pure function — safe to unit test.
|
|
21
|
+
*/
|
|
22
|
+
export function buildRollbackPreview(currentJson, targetJson) {
|
|
23
|
+
const diff = diffWorkflowNodes(currentJson, targetJson);
|
|
24
|
+
const lines = [];
|
|
25
|
+
if (diff.added.length === 0 && diff.removed.length === 0) {
|
|
26
|
+
lines.push(` No node changes (${diff.unchanged} node${diff.unchanged !== 1 ? 's' : ''} unchanged)`);
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
if (diff.added.length > 0) {
|
|
30
|
+
lines.push(` + Nodes added back: ${diff.added.join(', ')}`);
|
|
31
|
+
}
|
|
32
|
+
if (diff.removed.length > 0) {
|
|
33
|
+
lines.push(` - Nodes removed: ${diff.removed.join(', ')}`);
|
|
34
|
+
}
|
|
35
|
+
if (diff.unchanged > 0) {
|
|
36
|
+
lines.push(` Unchanged nodes: ${diff.unchanged}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return lines.join('\n');
|
|
40
|
+
}
|
|
41
|
+
async function findWorkflowFiles(rootDir) {
|
|
42
|
+
const choices = [];
|
|
43
|
+
async function scan(dir) {
|
|
44
|
+
let entries;
|
|
45
|
+
try {
|
|
46
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
for (const entry of entries) {
|
|
52
|
+
const fullPath = path.join(dir, entry.name);
|
|
53
|
+
if (entry.isDirectory()) {
|
|
54
|
+
await scan(fullPath);
|
|
55
|
+
}
|
|
56
|
+
else if (entry.name.endsWith('.json')) {
|
|
57
|
+
const rel = path.relative(rootDir, fullPath);
|
|
58
|
+
let label = rel;
|
|
59
|
+
try {
|
|
60
|
+
const raw = await fs.readFile(fullPath, 'utf-8');
|
|
61
|
+
const parsed = JSON.parse(raw);
|
|
62
|
+
if (parsed.name)
|
|
63
|
+
label = `${parsed.name} (${rel})`;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// use rel as label
|
|
67
|
+
}
|
|
68
|
+
choices.push({ name: label, value: fullPath });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
await scan(rootDir);
|
|
73
|
+
return choices;
|
|
74
|
+
}
|
|
75
|
+
export default class Rollback extends Command {
|
|
76
|
+
static args = {
|
|
77
|
+
workflow: Args.string({
|
|
78
|
+
description: 'Path to workflow JSON file (omit for interactive menu)',
|
|
79
|
+
required: false,
|
|
80
|
+
}),
|
|
81
|
+
};
|
|
82
|
+
static description = 'Restore a workflow to a previous git-tracked version';
|
|
83
|
+
static examples = [
|
|
84
|
+
'<%= config.bin %> <%= command.id %>',
|
|
85
|
+
'<%= config.bin %> <%= command.id %> ./workflows/slack-notifier/workflow.json',
|
|
86
|
+
'<%= config.bin %> <%= command.id %> ./workflows/slack-notifier/workflow.json --deploy',
|
|
87
|
+
];
|
|
88
|
+
static flags = {
|
|
89
|
+
instance: Flags.string({
|
|
90
|
+
char: 'i',
|
|
91
|
+
default: 'production',
|
|
92
|
+
description: 'n8n instance name (from config)',
|
|
93
|
+
}),
|
|
94
|
+
deploy: Flags.boolean({
|
|
95
|
+
char: 'd',
|
|
96
|
+
default: false,
|
|
97
|
+
description: 'Deploy the restored workflow to n8n after rollback',
|
|
98
|
+
}),
|
|
99
|
+
dir: Flags.string({
|
|
100
|
+
description: 'Directory to scan for workflows (default: ./workflows)',
|
|
101
|
+
}),
|
|
102
|
+
};
|
|
103
|
+
async run() {
|
|
104
|
+
this.log(theme.brand());
|
|
105
|
+
const { args, flags } = await this.parse(Rollback);
|
|
106
|
+
this.log(theme.header('WORKFLOW ROLLBACK'));
|
|
107
|
+
const git = new GitService(process.cwd());
|
|
108
|
+
// 1. Verify we are inside a git repo
|
|
109
|
+
if (!(await git.isGitRepo())) {
|
|
110
|
+
this.error('Not a git repository. Rollback requires git history to restore from.');
|
|
111
|
+
}
|
|
112
|
+
// 2. Resolve workflow file path
|
|
113
|
+
let workflowPath = args.workflow;
|
|
114
|
+
if (!workflowPath) {
|
|
115
|
+
const { default: select } = await import('@inquirer/select');
|
|
116
|
+
const workflowsDir = flags.dir ?? path.join(process.cwd(), 'workflows');
|
|
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}. Pass a file path directly or use --dir to specify another directory.`);
|
|
121
|
+
}
|
|
122
|
+
workflowPath = await select({
|
|
123
|
+
message: 'Select a workflow to roll back:',
|
|
124
|
+
choices,
|
|
125
|
+
pageSize: 15,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
// 3. Get path relative to repo root (needed for git commands)
|
|
129
|
+
const relPath = await git.getRelativePath(path.resolve(workflowPath));
|
|
130
|
+
if (!relPath) {
|
|
131
|
+
this.error(`File "${workflowPath}" is outside the git repository and cannot be rolled back.`);
|
|
132
|
+
}
|
|
133
|
+
this.log(`\n${theme.label('Workflow')} ${theme.value(workflowPath)}`);
|
|
134
|
+
this.log(theme.divider(40));
|
|
135
|
+
// 4. Load git history for this file
|
|
136
|
+
const history = await git.getFileHistory(relPath);
|
|
137
|
+
if (history.length === 0) {
|
|
138
|
+
this.error(`No git history found for "${relPath}". Commit the workflow first to enable rollback.`);
|
|
139
|
+
}
|
|
140
|
+
if (history.length === 1) {
|
|
141
|
+
this.error(`Only one commit found for "${relPath}". There is no earlier version to roll back to.`);
|
|
142
|
+
}
|
|
143
|
+
// 5. Let user pick a commit to restore
|
|
144
|
+
const { default: select } = await import('@inquirer/select');
|
|
145
|
+
const commitChoices = buildRollbackChoices(history);
|
|
146
|
+
this.log(theme.subHeader('Git History'));
|
|
147
|
+
const targetHash = await select({
|
|
148
|
+
message: 'Select the version to restore:',
|
|
149
|
+
choices: commitChoices,
|
|
150
|
+
pageSize: 15,
|
|
151
|
+
});
|
|
152
|
+
if (targetHash === history[0].hash) {
|
|
153
|
+
this.log(theme.warn('That is already the current version (HEAD). Nothing to restore.'));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
// 6. Preview the diff
|
|
157
|
+
this.log(theme.subHeader('Change Preview'));
|
|
158
|
+
try {
|
|
159
|
+
const currentContent = await fs.readFile(path.resolve(workflowPath), 'utf-8');
|
|
160
|
+
const targetContent = await git.getFileAtCommit(targetHash, relPath);
|
|
161
|
+
const currentJson = JSON.parse(currentContent);
|
|
162
|
+
const targetJson = JSON.parse(targetContent);
|
|
163
|
+
const preview = buildRollbackPreview(currentJson, targetJson);
|
|
164
|
+
this.log(preview);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
this.log(theme.muted(' (Could not generate diff preview)'));
|
|
168
|
+
}
|
|
169
|
+
this.log('');
|
|
170
|
+
// 7. Confirm
|
|
171
|
+
const { default: confirm } = await import('@inquirer/confirm');
|
|
172
|
+
const selectedCommit = history.find(c => c.hash === targetHash);
|
|
173
|
+
const proceed = await confirm({
|
|
174
|
+
message: `Restore to commit ${targetHash.slice(0, 7)} — "${selectedCommit.message}"?`,
|
|
175
|
+
default: false,
|
|
176
|
+
});
|
|
177
|
+
if (!proceed) {
|
|
178
|
+
this.log(theme.muted('Rollback cancelled.'));
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
// 8. Restore — write file content from target commit, do NOT run git checkout
|
|
182
|
+
this.log(theme.agent('Restoring file...'));
|
|
183
|
+
const restoredContent = await git.getFileAtCommit(targetHash, relPath);
|
|
184
|
+
await fs.writeFile(path.resolve(workflowPath), restoredContent, 'utf-8');
|
|
185
|
+
this.log(theme.done(`Restored "${relPath}" to ${targetHash.slice(0, 7)} — "${selectedCommit.message}"`));
|
|
186
|
+
// 9. Optionally deploy
|
|
187
|
+
if (flags.deploy) {
|
|
188
|
+
this.log(theme.info('Deploying restored workflow to n8n...'));
|
|
189
|
+
try {
|
|
190
|
+
await this.config.runCommand('deploy', [path.resolve(workflowPath), '--instance', flags.instance]);
|
|
191
|
+
}
|
|
192
|
+
catch (err) {
|
|
193
|
+
this.log(theme.error(`Deploy failed: ${err.message}`));
|
|
194
|
+
this.log(theme.muted('The file has been restored locally. Run `n8m deploy` manually to push it.'));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
this.log(theme.muted(` Run \`n8m deploy ${workflowPath}\` to push the restored version to n8n.`));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"$id": "https://n8m.dev/fixture-schema.json",
|
|
4
|
+
"title": "WorkflowFixture",
|
|
5
|
+
"description": "n8m fixture file — captures a real n8n execution for offline testing and replay.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": ["version", "workflowId", "workflowName", "workflow", "execution"],
|
|
8
|
+
"additionalProperties": true,
|
|
9
|
+
"properties": {
|
|
10
|
+
"$schema": {
|
|
11
|
+
"type": "string",
|
|
12
|
+
"description": "JSON Schema reference for editor autocomplete."
|
|
13
|
+
},
|
|
14
|
+
"version": {
|
|
15
|
+
"const": "1.0",
|
|
16
|
+
"description": "Fixture format version. Always \"1.0\"."
|
|
17
|
+
},
|
|
18
|
+
"capturedAt": {
|
|
19
|
+
"type": "string",
|
|
20
|
+
"format": "date-time",
|
|
21
|
+
"description": "ISO 8601 timestamp of when the fixture was captured."
|
|
22
|
+
},
|
|
23
|
+
"workflowId": {
|
|
24
|
+
"type": "string",
|
|
25
|
+
"description": "The n8n workflow ID this fixture was captured from."
|
|
26
|
+
},
|
|
27
|
+
"workflowName": {
|
|
28
|
+
"type": "string",
|
|
29
|
+
"description": "Human-readable workflow name."
|
|
30
|
+
},
|
|
31
|
+
"workflow": {
|
|
32
|
+
"type": "object",
|
|
33
|
+
"description": "Full n8n workflow JSON (nodes, connections, settings, etc.).",
|
|
34
|
+
"required": ["nodes", "connections"],
|
|
35
|
+
"additionalProperties": true,
|
|
36
|
+
"properties": {
|
|
37
|
+
"name": { "type": "string" },
|
|
38
|
+
"nodes": {
|
|
39
|
+
"type": "array",
|
|
40
|
+
"items": {
|
|
41
|
+
"type": "object",
|
|
42
|
+
"additionalProperties": true,
|
|
43
|
+
"required": ["name", "type"],
|
|
44
|
+
"properties": {
|
|
45
|
+
"id": { "type": "string" },
|
|
46
|
+
"name": { "type": "string", "description": "Unique node name within this workflow." },
|
|
47
|
+
"type": { "type": "string", "description": "e.g. n8n-nodes-base.code" },
|
|
48
|
+
"typeVersion": { "type": "number" },
|
|
49
|
+
"position": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 },
|
|
50
|
+
"parameters": { "type": "object", "additionalProperties": true }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"connections": {
|
|
55
|
+
"type": "object",
|
|
56
|
+
"description": "Maps source node names to their output connections.",
|
|
57
|
+
"additionalProperties": {
|
|
58
|
+
"type": "object",
|
|
59
|
+
"properties": {
|
|
60
|
+
"main": {
|
|
61
|
+
"type": "array",
|
|
62
|
+
"items": {
|
|
63
|
+
"type": "array",
|
|
64
|
+
"items": {
|
|
65
|
+
"type": "object",
|
|
66
|
+
"properties": {
|
|
67
|
+
"node": { "type": "string" },
|
|
68
|
+
"type": { "type": "string" },
|
|
69
|
+
"index": { "type": "number" }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
"execution": {
|
|
80
|
+
"type": "object",
|
|
81
|
+
"description": "The captured n8n execution result.",
|
|
82
|
+
"required": ["status", "data"],
|
|
83
|
+
"additionalProperties": true,
|
|
84
|
+
"properties": {
|
|
85
|
+
"id": { "type": "string" },
|
|
86
|
+
"status": {
|
|
87
|
+
"type": "string",
|
|
88
|
+
"enum": ["success", "error", "crashed", "waiting", "running"],
|
|
89
|
+
"description": "Final execution status."
|
|
90
|
+
},
|
|
91
|
+
"startedAt": { "type": "string", "format": "date-time" },
|
|
92
|
+
"data": {
|
|
93
|
+
"type": "object",
|
|
94
|
+
"required": ["resultData"],
|
|
95
|
+
"properties": {
|
|
96
|
+
"resultData": {
|
|
97
|
+
"type": "object",
|
|
98
|
+
"required": ["runData"],
|
|
99
|
+
"properties": {
|
|
100
|
+
"error": {
|
|
101
|
+
"description": "Top-level execution error, if any. null on success.",
|
|
102
|
+
"oneOf": [
|
|
103
|
+
{ "type": "null" },
|
|
104
|
+
{
|
|
105
|
+
"type": "object",
|
|
106
|
+
"additionalProperties": true,
|
|
107
|
+
"properties": {
|
|
108
|
+
"message": { "type": "string" },
|
|
109
|
+
"description": { "type": "string" },
|
|
110
|
+
"node": {
|
|
111
|
+
"description": "Name of the node that failed.",
|
|
112
|
+
"oneOf": [
|
|
113
|
+
{ "type": "string" },
|
|
114
|
+
{
|
|
115
|
+
"type": "object",
|
|
116
|
+
"properties": {
|
|
117
|
+
"name": { "type": "string" },
|
|
118
|
+
"type": { "type": "string" }
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
]
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
]
|
|
126
|
+
},
|
|
127
|
+
"runData": {
|
|
128
|
+
"type": "object",
|
|
129
|
+
"description": "Per-node output data. Keys are exact node names.",
|
|
130
|
+
"additionalProperties": {
|
|
131
|
+
"type": "array",
|
|
132
|
+
"items": {
|
|
133
|
+
"type": "object",
|
|
134
|
+
"additionalProperties": true,
|
|
135
|
+
"properties": {
|
|
136
|
+
"json": {
|
|
137
|
+
"type": "object",
|
|
138
|
+
"additionalProperties": true,
|
|
139
|
+
"description": "The node's output JSON data."
|
|
140
|
+
},
|
|
141
|
+
"binary": {
|
|
142
|
+
"type": "object",
|
|
143
|
+
"additionalProperties": true,
|
|
144
|
+
"description": "Binary field metadata, if any."
|
|
145
|
+
},
|
|
146
|
+
"error": {
|
|
147
|
+
"type": "object",
|
|
148
|
+
"additionalProperties": true,
|
|
149
|
+
"description": "Node-level error, if this node failed."
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
package/dist/help.d.ts
ADDED
package/dist/help.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Help } from '@oclif/core';
|
|
2
|
+
import { theme } from './utils/theme.js';
|
|
3
|
+
export default class CustomHelp extends Help {
|
|
4
|
+
async showRootHelp() {
|
|
5
|
+
this.log(theme.brand());
|
|
6
|
+
await super.showRootHelp();
|
|
7
|
+
}
|
|
8
|
+
async showCommandHelp(command) {
|
|
9
|
+
this.log(theme.brand());
|
|
10
|
+
await super.showCommandHelp(command);
|
|
11
|
+
}
|
|
12
|
+
}
|