n8nac 2.2.1 → 2.3.0-next.55
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 +32 -4
- package/dist/commands/base.d.ts.map +1 -1
- package/dist/commands/base.js +17 -10
- package/dist/commands/base.js.map +1 -1
- package/dist/commands/promote.d.ts +83 -3
- package/dist/commands/promote.d.ts.map +1 -1
- package/dist/commands/promote.js +616 -67
- package/dist/commands/promote.js.map +1 -1
- package/dist/core/services/n8n-api-client.d.ts +2 -1
- package/dist/core/services/n8n-api-client.d.ts.map +1 -1
- package/dist/core/services/n8n-api-client.js +36 -5
- package/dist/core/services/n8n-api-client.js.map +1 -1
- package/dist/core/services/sync-manager.d.ts.map +1 -1
- package/dist/core/services/sync-manager.js +6 -5
- package/dist/core/services/sync-manager.js.map +1 -1
- package/dist/core/types.d.ts +1 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/index.js +18 -13
- package/dist/index.js.map +1 -1
- package/dist/services/config-service.d.ts +30 -5
- package/dist/services/config-service.d.ts.map +1 -1
- package/dist/services/config-service.js +340 -68
- package/dist/services/config-service.js.map +1 -1
- package/package.json +4 -4
package/dist/commands/promote.js
CHANGED
|
@@ -1,14 +1,24 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import chalk from 'chalk';
|
|
4
|
+
import { TypeScriptParser, WorkflowBuilder } from '@n8n-as-code/transformer';
|
|
4
5
|
import { ConfigService } from '../services/config-service.js';
|
|
6
|
+
import { N8nApiClient } from '../core/index.js';
|
|
7
|
+
import { WorkflowTransformerAdapter } from '../core/services/workflow-transformer-adapter.js';
|
|
5
8
|
import { SyncCommand } from './sync.js';
|
|
9
|
+
const EXECUTE_WORKFLOW_TYPE_SUFFIX = 'executeworkflow';
|
|
6
10
|
export class PromoteCommand {
|
|
7
11
|
configService;
|
|
8
|
-
|
|
12
|
+
dependencies;
|
|
13
|
+
targetWorkflowInventory;
|
|
14
|
+
targetCredentialInventory;
|
|
15
|
+
constructor(configService = new ConfigService(), dependencies = {}) {
|
|
9
16
|
this.configService = configService;
|
|
17
|
+
this.dependencies = dependencies;
|
|
10
18
|
}
|
|
11
19
|
async run(sourceWorkflowPath, options) {
|
|
20
|
+
this.targetWorkflowInventory = undefined;
|
|
21
|
+
this.targetCredentialInventory = undefined;
|
|
12
22
|
const source = await this.configService.prepareEnvironment(options.from);
|
|
13
23
|
const target = await this.configService.prepareEnvironment(options.to);
|
|
14
24
|
if (source.environmentId === target.environmentId) {
|
|
@@ -16,85 +26,463 @@ export class PromoteCommand {
|
|
|
16
26
|
}
|
|
17
27
|
const sourceRoot = this.getEnvironmentWorkflowRoot(source);
|
|
18
28
|
const targetRoot = this.getEnvironmentWorkflowRoot(target);
|
|
29
|
+
const configPath = this.resolvePromotionConfigPath(options.promotionConfig);
|
|
30
|
+
const config = this.loadPromotionConfig(configPath);
|
|
31
|
+
const routeKey = `${source.environmentName}->${target.environmentName}`;
|
|
32
|
+
const route = this.ensureRoute(config, routeKey);
|
|
33
|
+
const sources = await this.loadSourceWorkflows(sourceWorkflowPath, sourceRoot, targetRoot);
|
|
34
|
+
this.initializeTargetActions(sources, route);
|
|
35
|
+
const indexes = {
|
|
36
|
+
sourceById: new Map(),
|
|
37
|
+
sourceByName: new Map(),
|
|
38
|
+
};
|
|
39
|
+
for (const item of sources) {
|
|
40
|
+
if (item.workflow.id)
|
|
41
|
+
indexes.sourceById.set(item.workflow.id, item);
|
|
42
|
+
const byName = indexes.sourceByName.get(item.workflow.name) ?? [];
|
|
43
|
+
byName.push(item);
|
|
44
|
+
indexes.sourceByName.set(item.workflow.name, byName);
|
|
45
|
+
}
|
|
46
|
+
await this.discoverTargetWorkflowIds(sources, target, route, indexes, options);
|
|
47
|
+
const planned = await this.planWorkflows(sources, target, route, indexes);
|
|
48
|
+
const blockingProblems = planned.flatMap((workflow) => workflow.problems);
|
|
49
|
+
if (blockingProblems.length > 0) {
|
|
50
|
+
const result = this.buildResult(source, target, routeKey, configPath, planned, options);
|
|
51
|
+
this.printResult(result, options);
|
|
52
|
+
throw new Error(`Promotion blocked by ${blockingProblems.length} problem${blockingProblems.length === 1 ? '' : 's'}.`);
|
|
53
|
+
}
|
|
54
|
+
if (options.dryRun) {
|
|
55
|
+
const result = this.buildResult(source, target, routeKey, configPath, planned, options);
|
|
56
|
+
this.printResult(result, options);
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
const applied = await this.applyPromotion(sources, target, route, indexes, options);
|
|
60
|
+
const applyProblems = applied.flatMap((workflow) => workflow.problems);
|
|
61
|
+
if (applyProblems.length > 0) {
|
|
62
|
+
const result = this.buildResult(source, target, routeKey, configPath, applied, options);
|
|
63
|
+
this.printResult(result, options);
|
|
64
|
+
throw new Error(`Promotion blocked by ${applyProblems.length} problem${applyProblems.length === 1 ? '' : 's'}.`);
|
|
65
|
+
}
|
|
66
|
+
this.savePromotionConfig(configPath, config);
|
|
67
|
+
const result = this.buildResult(source, target, routeKey, configPath, applied, options);
|
|
68
|
+
this.printResult(result, options);
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
async loadSourceWorkflows(sourceWorkflowPath, sourceRoot, targetRoot) {
|
|
72
|
+
const sourcePaths = sourceWorkflowPath
|
|
73
|
+
? [this.resolveSourceWorkflowPath(sourceWorkflowPath, sourceRoot)]
|
|
74
|
+
: this.listSourceWorkflowPaths(sourceRoot);
|
|
75
|
+
if (sourcePaths.length === 0) {
|
|
76
|
+
throw new Error(`No TypeScript workflow files found in source environment workflowsPath: ${sourceRoot}`);
|
|
77
|
+
}
|
|
78
|
+
const sourceRootRealPath = this.realpathExisting(sourceRoot);
|
|
79
|
+
const targetRootRealPath = fs.existsSync(targetRoot) ? this.realpathExisting(targetRoot) : path.resolve(targetRoot);
|
|
80
|
+
const sources = [];
|
|
81
|
+
for (const sourcePath of sourcePaths) {
|
|
82
|
+
const sourceRealPath = this.realpathExisting(sourcePath);
|
|
83
|
+
if (!this.isPathInside(sourceRealPath, sourceRootRealPath)) {
|
|
84
|
+
throw new Error(`Source workflow must be inside the source environment workflowsPath: ${sourceRoot}`);
|
|
85
|
+
}
|
|
86
|
+
const relativePath = path.relative(sourceRoot, sourcePath);
|
|
87
|
+
const targetPath = path.resolve(targetRoot, relativePath);
|
|
88
|
+
this.assertTargetPath(targetPath, targetRoot, targetRootRealPath);
|
|
89
|
+
const targetExists = fs.existsSync(targetPath);
|
|
90
|
+
if (targetExists && !this.isPathInside(this.realpathExisting(targetPath), targetRootRealPath)) {
|
|
91
|
+
throw new Error('Resolved target path escapes the target environment workflowsPath.');
|
|
92
|
+
}
|
|
93
|
+
const workflow = await compileWorkflowForPromotion(fs.readFileSync(sourcePath, 'utf8'));
|
|
94
|
+
const sourceKey = workflow.id || workflow.name;
|
|
95
|
+
sources.push({
|
|
96
|
+
sourcePath,
|
|
97
|
+
targetPath,
|
|
98
|
+
workflow,
|
|
99
|
+
sourceKey,
|
|
100
|
+
targetExists,
|
|
101
|
+
targetWorkflowId: targetExists ? readWorkflowDecoratorProperty(fs.readFileSync(targetPath, 'utf8'), 'id') : undefined,
|
|
102
|
+
action: 'create',
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return sources;
|
|
106
|
+
}
|
|
107
|
+
resolveSourceWorkflowPath(sourceWorkflowPath, sourceRoot) {
|
|
19
108
|
const sourcePath = path.resolve(sourceWorkflowPath);
|
|
20
109
|
if (!fs.existsSync(sourcePath)) {
|
|
21
110
|
throw new Error(`Source workflow not found: ${sourcePath}`);
|
|
22
111
|
}
|
|
23
|
-
const sourceRootRealPath = this.realpathExisting(sourceRoot);
|
|
24
|
-
const sourceRealPath = this.realpathExisting(sourcePath);
|
|
25
|
-
if (!this.isPathInside(sourceRealPath, sourceRootRealPath)) {
|
|
26
|
-
throw new Error(`Source workflow must be inside the source environment sync scope: ${sourceRoot}`);
|
|
27
|
-
}
|
|
28
112
|
if (!sourcePath.endsWith('.workflow.ts')) {
|
|
29
113
|
throw new Error('Promotion currently supports TypeScript workflow files (*.workflow.ts).');
|
|
30
114
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const targetRootRealPath = fs.existsSync(targetRoot) ? this.realpathExisting(targetRoot) : path.resolve(targetRoot);
|
|
37
|
-
const targetParentRealPath = this.realpathExistingParent(path.dirname(targetPath));
|
|
38
|
-
if (fs.existsSync(targetRoot) && targetParentRealPath && !this.isPathInside(targetParentRealPath, targetRootRealPath)) {
|
|
39
|
-
throw new Error('Resolved target path escapes the target environment sync scope.');
|
|
115
|
+
return sourcePath;
|
|
116
|
+
}
|
|
117
|
+
listSourceWorkflowPaths(sourceRoot) {
|
|
118
|
+
if (!fs.existsSync(sourceRoot)) {
|
|
119
|
+
throw new Error(`Source environment workflowsPath does not exist: ${sourceRoot}`);
|
|
40
120
|
}
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
121
|
+
const walk = (directory) => fs.readdirSync(directory, { withFileTypes: true })
|
|
122
|
+
.flatMap((entry) => {
|
|
123
|
+
if (entry.name.startsWith('.'))
|
|
124
|
+
return [];
|
|
125
|
+
const entryPath = path.join(directory, entry.name);
|
|
126
|
+
if (entry.isDirectory())
|
|
127
|
+
return walk(entryPath);
|
|
128
|
+
return entry.name.endsWith('.workflow.ts') ? [entryPath] : [];
|
|
129
|
+
});
|
|
130
|
+
return walk(sourceRoot).sort();
|
|
131
|
+
}
|
|
132
|
+
initializeTargetActions(sources, route) {
|
|
133
|
+
for (const item of sources) {
|
|
134
|
+
const binding = route.bindings?.workflows?.[item.sourceKey];
|
|
135
|
+
if (binding) {
|
|
136
|
+
item.targetWorkflowId = binding;
|
|
137
|
+
}
|
|
138
|
+
item.action = item.targetWorkflowId ? 'update' : 'create';
|
|
44
139
|
}
|
|
45
|
-
|
|
46
|
-
|
|
140
|
+
}
|
|
141
|
+
async discoverTargetWorkflowIds(sources, target, route, indexes, options) {
|
|
142
|
+
const shouldPersistBindings = !options.dryRun;
|
|
143
|
+
const workflows = await this.getTargetWorkflows(target);
|
|
144
|
+
indexes.targetWorkflowsById = new Map(workflows.map((workflow) => [workflow.id, workflow]));
|
|
145
|
+
indexes.targetWorkflowsByName = groupWorkflowsByName(workflows);
|
|
146
|
+
for (const item of sources) {
|
|
147
|
+
if (item.targetWorkflowId)
|
|
148
|
+
continue;
|
|
149
|
+
const override = this.findOverride(route.workflowOverrides, item.sourceKey, item.workflow.name);
|
|
150
|
+
const targetWorkflow = this.resolveWorkflowOverride(override, indexes);
|
|
151
|
+
if (targetWorkflow) {
|
|
152
|
+
item.targetWorkflowId = targetWorkflow.id;
|
|
153
|
+
item.action = 'update';
|
|
154
|
+
if (shouldPersistBindings)
|
|
155
|
+
route.bindings.workflows[item.sourceKey] = targetWorkflow.id;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
const targetName = this.applyNameRules(item.workflow.name, 'workflow', route);
|
|
159
|
+
const matches = indexes.targetWorkflowsByName.get(targetName) ?? [];
|
|
160
|
+
if (matches.length === 1) {
|
|
161
|
+
item.targetWorkflowId = matches[0].id;
|
|
162
|
+
item.action = 'update';
|
|
163
|
+
if (shouldPersistBindings)
|
|
164
|
+
route.bindings.workflows[item.sourceKey] = matches[0].id;
|
|
165
|
+
}
|
|
47
166
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if (options.dryRun) {
|
|
65
|
-
this.printResult(result, options);
|
|
66
|
-
return result;
|
|
167
|
+
}
|
|
168
|
+
async planWorkflows(sources, target, route, indexes) {
|
|
169
|
+
const planned = [];
|
|
170
|
+
for (const item of sources) {
|
|
171
|
+
const transformed = await this.transformWorkflow(item, target, route, indexes);
|
|
172
|
+
planned.push({
|
|
173
|
+
sourcePath: item.sourcePath,
|
|
174
|
+
targetPath: item.targetPath,
|
|
175
|
+
sourceWorkflowId: item.workflow.id || undefined,
|
|
176
|
+
sourceWorkflowName: item.workflow.name,
|
|
177
|
+
targetWorkflowId: item.targetWorkflowId,
|
|
178
|
+
action: item.action,
|
|
179
|
+
status: transformed.problems.length > 0 ? 'blocked' : 'planned',
|
|
180
|
+
substitutions: transformed.substitutions,
|
|
181
|
+
problems: transformed.problems,
|
|
182
|
+
});
|
|
67
183
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
delete
|
|
184
|
+
return planned;
|
|
185
|
+
}
|
|
186
|
+
async applyPromotion(sources, target, route, indexes, options) {
|
|
187
|
+
const remaining = new Map(sources.map((item) => [item.sourceKey, item]));
|
|
188
|
+
const applied = [];
|
|
189
|
+
while (remaining.size > 0) {
|
|
190
|
+
let progressed = false;
|
|
191
|
+
for (const item of Array.from(remaining.values())) {
|
|
192
|
+
const transformed = await this.transformWorkflow(item, target, route, indexes);
|
|
193
|
+
const pending = transformed.pendingWorkflowSourceKeys.filter((sourceKey) => remaining.has(sourceKey));
|
|
194
|
+
if (pending.length > 0) {
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (transformed.problems.length > 0) {
|
|
198
|
+
applied.push(this.workflowResult(item, transformed, 'blocked'));
|
|
199
|
+
remaining.delete(item.sourceKey);
|
|
200
|
+
progressed = true;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (item.targetExists && !item.targetWorkflowId && !options.overwrite) {
|
|
204
|
+
applied.push(this.workflowResult(item, transformed, 'blocked', [{
|
|
205
|
+
kind: 'target-file',
|
|
206
|
+
message: `Target workflow already exists: ${item.targetPath}. Re-run with --overwrite to replace it.`,
|
|
207
|
+
}]));
|
|
208
|
+
remaining.delete(item.sourceKey);
|
|
209
|
+
progressed = true;
|
|
210
|
+
continue;
|
|
84
211
|
}
|
|
85
|
-
|
|
86
|
-
|
|
212
|
+
const targetTs = await WorkflowTransformerAdapter.convertToTypeScript(transformed.workflow, {
|
|
213
|
+
format: true,
|
|
214
|
+
commentStyle: 'verbose',
|
|
215
|
+
});
|
|
216
|
+
fs.mkdirSync(path.dirname(item.targetPath), { recursive: true });
|
|
217
|
+
fs.writeFileSync(item.targetPath, targetTs, 'utf8');
|
|
218
|
+
let pushedWorkflowId;
|
|
219
|
+
if (options.push !== false) {
|
|
220
|
+
pushedWorkflowId = await this.pushWorkflow(target, item.targetPath);
|
|
221
|
+
if (pushedWorkflowId) {
|
|
222
|
+
item.targetWorkflowId = pushedWorkflowId;
|
|
223
|
+
route.bindings.workflows[item.sourceKey] = pushedWorkflowId;
|
|
224
|
+
}
|
|
87
225
|
}
|
|
226
|
+
else if (item.targetWorkflowId) {
|
|
227
|
+
route.bindings.workflows[item.sourceKey] = item.targetWorkflowId;
|
|
228
|
+
}
|
|
229
|
+
applied.push({
|
|
230
|
+
...this.workflowResult(item, transformed, pushedWorkflowId ? 'pushed' : 'written'),
|
|
231
|
+
targetWorkflowId: pushedWorkflowId || item.targetWorkflowId,
|
|
232
|
+
});
|
|
233
|
+
remaining.delete(item.sourceKey);
|
|
234
|
+
progressed = true;
|
|
235
|
+
}
|
|
236
|
+
if (!progressed) {
|
|
237
|
+
for (const item of remaining.values()) {
|
|
238
|
+
const transformed = await this.transformWorkflow(item, target, route, indexes);
|
|
239
|
+
applied.push(this.workflowResult(item, transformed, 'blocked', [{
|
|
240
|
+
kind: 'workflow',
|
|
241
|
+
message: 'Cannot resolve workflow reference order. Check for first-deploy circular Execute Workflow references.',
|
|
242
|
+
}]));
|
|
243
|
+
}
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return sources.map((sourceItem) => applied.find((item) => item.sourcePath === sourceItem.sourcePath)).filter(Boolean);
|
|
248
|
+
}
|
|
249
|
+
workflowResult(item, transformed, status, extraProblems = []) {
|
|
250
|
+
return {
|
|
251
|
+
sourcePath: item.sourcePath,
|
|
252
|
+
targetPath: item.targetPath,
|
|
253
|
+
sourceWorkflowId: item.workflow.id || undefined,
|
|
254
|
+
sourceWorkflowName: item.workflow.name,
|
|
255
|
+
targetWorkflowId: item.targetWorkflowId,
|
|
256
|
+
action: item.action,
|
|
257
|
+
status: extraProblems.length > 0 ? 'blocked' : status,
|
|
258
|
+
substitutions: transformed.substitutions,
|
|
259
|
+
problems: [...transformed.problems, ...extraProblems],
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
async transformWorkflow(item, target, route, indexes) {
|
|
263
|
+
const workflow = cloneWorkflow(item.workflow);
|
|
264
|
+
const substitutions = [];
|
|
265
|
+
const problems = [];
|
|
266
|
+
const pendingWorkflowSourceKeys = [];
|
|
267
|
+
if (item.targetWorkflowId) {
|
|
268
|
+
workflow.id = item.targetWorkflowId;
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
delete workflow.id;
|
|
272
|
+
}
|
|
273
|
+
workflow.projectId = target.projectId;
|
|
274
|
+
workflow.projectName = target.projectName;
|
|
275
|
+
delete workflow.homeProject;
|
|
276
|
+
delete workflow.isArchived;
|
|
277
|
+
substitutions.push({
|
|
278
|
+
kind: 'metadata',
|
|
279
|
+
field: 'workflow.id',
|
|
280
|
+
fromId: item.workflow.id || undefined,
|
|
281
|
+
toId: item.targetWorkflowId,
|
|
282
|
+
status: item.targetWorkflowId ? 'mapped' : 'unchanged',
|
|
283
|
+
});
|
|
284
|
+
for (const node of workflow.nodes ?? []) {
|
|
285
|
+
await this.remapNodeCredentials(node, target, route, indexes, substitutions, problems);
|
|
286
|
+
await this.remapWorkflowReferences(node, target, route, indexes, substitutions, problems, pendingWorkflowSourceKeys);
|
|
287
|
+
}
|
|
288
|
+
return { workflow, substitutions, problems, pendingWorkflowSourceKeys };
|
|
289
|
+
}
|
|
290
|
+
async remapNodeCredentials(node, target, route, indexes, substitutions, problems) {
|
|
291
|
+
if (!node.credentials || typeof node.credentials !== 'object')
|
|
292
|
+
return;
|
|
293
|
+
for (const [credentialType, credentialRef] of Object.entries(node.credentials)) {
|
|
294
|
+
const sourceId = String(credentialRef?.id ?? '').trim();
|
|
295
|
+
const sourceName = String(credentialRef?.name ?? '').trim();
|
|
296
|
+
const targetCredential = await this.resolveTargetCredential(target, route, indexes, credentialType, sourceId, sourceName);
|
|
297
|
+
if (!targetCredential) {
|
|
298
|
+
problems.push({
|
|
299
|
+
kind: 'credential',
|
|
300
|
+
nodeName: node.name,
|
|
301
|
+
ref: sourceId || sourceName,
|
|
302
|
+
message: `Cannot resolve credential "${sourceName || sourceId}" of type "${credentialType}" in target environment.`,
|
|
303
|
+
});
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
const targetId = String(targetCredential.id ?? '').trim();
|
|
307
|
+
const targetName = String(targetCredential.name ?? '').trim();
|
|
308
|
+
node.credentials[credentialType] = { id: targetId, name: targetName };
|
|
309
|
+
if (sourceId)
|
|
310
|
+
route.bindings.credentials[sourceId] = targetId;
|
|
311
|
+
substitutions.push({
|
|
312
|
+
kind: 'credential',
|
|
313
|
+
nodeName: node.name,
|
|
314
|
+
field: credentialType,
|
|
315
|
+
fromId: sourceId || undefined,
|
|
316
|
+
fromName: sourceName || undefined,
|
|
317
|
+
toId: targetId,
|
|
318
|
+
toName: targetName,
|
|
319
|
+
status: 'mapped',
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
async resolveTargetCredential(target, route, indexes, credentialType, sourceId, sourceName) {
|
|
324
|
+
await this.ensureTargetCredentialIndexes(target, indexes);
|
|
325
|
+
if (sourceId) {
|
|
326
|
+
const boundId = route.bindings?.credentials?.[sourceId];
|
|
327
|
+
if (boundId) {
|
|
328
|
+
return indexes.targetCredentialsById.get(boundId);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const override = this.findOverride(route.credentialOverrides, `${credentialType}::${sourceId}`, `${credentialType}::${sourceName}`, sourceId, sourceName);
|
|
332
|
+
if (override?.targetId) {
|
|
333
|
+
return indexes.targetCredentialsById.get(override.targetId);
|
|
334
|
+
}
|
|
335
|
+
if (override?.targetName) {
|
|
336
|
+
const matches = indexes.targetCredentialsByKey.get(`${credentialType}::${override.targetName}`) ?? [];
|
|
337
|
+
if (matches.length === 1)
|
|
338
|
+
return matches[0];
|
|
339
|
+
return undefined;
|
|
340
|
+
}
|
|
341
|
+
const targetName = this.applyNameRules(sourceName, 'credential', route);
|
|
342
|
+
const matches = indexes.targetCredentialsByKey.get(`${credentialType}::${targetName}`) ?? [];
|
|
343
|
+
if (matches.length === 1)
|
|
344
|
+
return matches[0];
|
|
345
|
+
return undefined;
|
|
346
|
+
}
|
|
347
|
+
async remapWorkflowReferences(node, target, route, indexes, substitutions, problems, pendingWorkflowSourceKeys) {
|
|
348
|
+
const nodeType = String(node.type ?? '').toLowerCase().split('.').pop();
|
|
349
|
+
if (nodeType !== EXECUTE_WORKFLOW_TYPE_SUFFIX)
|
|
350
|
+
return;
|
|
351
|
+
const refs = findWorkflowReferenceFields(node.parameters);
|
|
352
|
+
for (const ref of refs) {
|
|
353
|
+
const resolved = await this.resolveWorkflowReference(ref.value, target, route, indexes);
|
|
354
|
+
if (resolved.status === 'mapped') {
|
|
355
|
+
ref.set(resolved.targetId);
|
|
356
|
+
substitutions.push({
|
|
357
|
+
kind: 'workflow',
|
|
358
|
+
nodeName: node.name,
|
|
359
|
+
field: ref.path,
|
|
360
|
+
fromId: ref.value,
|
|
361
|
+
fromName: resolved.sourceName,
|
|
362
|
+
toId: resolved.targetId,
|
|
363
|
+
toName: resolved.targetName,
|
|
364
|
+
status: 'mapped',
|
|
365
|
+
});
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
if (resolved.status === 'pending-create') {
|
|
369
|
+
pendingWorkflowSourceKeys.push(resolved.sourceKey);
|
|
370
|
+
substitutions.push({
|
|
371
|
+
kind: 'workflow',
|
|
372
|
+
nodeName: node.name,
|
|
373
|
+
field: ref.path,
|
|
374
|
+
fromId: ref.value,
|
|
375
|
+
fromName: resolved.sourceName,
|
|
376
|
+
status: 'pending-create',
|
|
377
|
+
});
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
problems.push({
|
|
381
|
+
kind: 'workflow',
|
|
382
|
+
nodeName: node.name,
|
|
383
|
+
ref: ref.value,
|
|
384
|
+
message: `Cannot resolve workflow reference "${ref.value}" in target environment.`,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
async resolveWorkflowReference(sourceRef, target, route, indexes) {
|
|
389
|
+
const boundId = route.bindings?.workflows?.[sourceRef];
|
|
390
|
+
if (boundId)
|
|
391
|
+
return { status: 'mapped', targetId: boundId };
|
|
392
|
+
const sourceItem = indexes.sourceById.get(sourceRef) ?? unique(indexes.sourceByName.get(sourceRef));
|
|
393
|
+
if (sourceItem) {
|
|
394
|
+
const targetId = sourceItem.targetWorkflowId || route.bindings?.workflows?.[sourceItem.sourceKey];
|
|
395
|
+
if (targetId) {
|
|
396
|
+
return { status: 'mapped', targetId, sourceName: sourceItem.workflow.name };
|
|
397
|
+
}
|
|
398
|
+
return { status: 'pending-create', sourceKey: sourceItem.sourceKey, sourceName: sourceItem.workflow.name };
|
|
399
|
+
}
|
|
400
|
+
const override = this.findOverride(route.workflowOverrides, sourceRef);
|
|
401
|
+
if (override) {
|
|
402
|
+
await this.ensureTargetWorkflowIndexes(target, indexes);
|
|
403
|
+
const workflow = this.resolveWorkflowOverride(override, indexes);
|
|
404
|
+
if (workflow)
|
|
405
|
+
return { status: 'mapped', targetId: workflow.id, targetName: workflow.name };
|
|
406
|
+
}
|
|
407
|
+
await this.ensureTargetWorkflowIndexes(target, indexes);
|
|
408
|
+
const targetName = this.applyNameRules(sourceRef, 'workflow', route);
|
|
409
|
+
const matches = indexes.targetWorkflowsByName.get(targetName) ?? [];
|
|
410
|
+
if (matches.length === 1) {
|
|
411
|
+
return { status: 'mapped', targetId: matches[0].id, targetName: matches[0].name };
|
|
412
|
+
}
|
|
413
|
+
return { status: 'missing' };
|
|
414
|
+
}
|
|
415
|
+
async ensureTargetWorkflowIndexes(target, indexes) {
|
|
416
|
+
if (indexes.targetWorkflowsById && indexes.targetWorkflowsByName)
|
|
417
|
+
return;
|
|
418
|
+
const workflows = await this.getTargetWorkflows(target);
|
|
419
|
+
indexes.targetWorkflowsById = new Map(workflows.map((workflow) => [workflow.id, workflow]));
|
|
420
|
+
indexes.targetWorkflowsByName = groupWorkflowsByName(workflows);
|
|
421
|
+
}
|
|
422
|
+
async ensureTargetCredentialIndexes(target, indexes) {
|
|
423
|
+
if (indexes.targetCredentialsById && indexes.targetCredentialsByKey)
|
|
424
|
+
return;
|
|
425
|
+
const credentials = await this.getTargetCredentials(target);
|
|
426
|
+
indexes.targetCredentialsById = new Map(credentials.map((credential) => [String(credential.id ?? ''), credential]));
|
|
427
|
+
indexes.targetCredentialsByKey = new Map();
|
|
428
|
+
for (const credential of credentials) {
|
|
429
|
+
const type = String(credential.type ?? '').trim();
|
|
430
|
+
const name = String(credential.name ?? '').trim();
|
|
431
|
+
const key = `${type}::${name}`;
|
|
432
|
+
const existing = indexes.targetCredentialsByKey.get(key) ?? [];
|
|
433
|
+
existing.push(credential);
|
|
434
|
+
indexes.targetCredentialsByKey.set(key, existing);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
async getTargetWorkflows(target) {
|
|
438
|
+
this.targetWorkflowInventory ??= this.getClient(target).getAllWorkflows(target.projectId);
|
|
439
|
+
return this.targetWorkflowInventory;
|
|
440
|
+
}
|
|
441
|
+
async getTargetCredentials(target) {
|
|
442
|
+
this.targetCredentialInventory ??= this.getClient(target).listCredentials();
|
|
443
|
+
return this.targetCredentialInventory;
|
|
444
|
+
}
|
|
445
|
+
getClient(environment) {
|
|
446
|
+
if (this.dependencies.createClient)
|
|
447
|
+
return this.dependencies.createClient(environment);
|
|
448
|
+
if (!environment.host || !environment.apiKey) {
|
|
449
|
+
throw new Error(`Environment "${environment.environmentName}" needs a host and API key before promotion can inspect target references.`);
|
|
450
|
+
}
|
|
451
|
+
return new N8nApiClient({ host: environment.host, apiKey: environment.apiKey });
|
|
452
|
+
}
|
|
453
|
+
async pushWorkflow(target, targetPath) {
|
|
454
|
+
if (this.dependencies.pushWorkflow) {
|
|
455
|
+
return this.dependencies.pushWorkflow(target, targetPath);
|
|
456
|
+
}
|
|
457
|
+
const previousEnvironment = process.env.N8NAC_ENVIRONMENT;
|
|
458
|
+
try {
|
|
459
|
+
process.env.N8NAC_ENVIRONMENT = target.environmentId;
|
|
460
|
+
return await new SyncCommand().pushOne(targetPath);
|
|
461
|
+
}
|
|
462
|
+
finally {
|
|
463
|
+
if (previousEnvironment === undefined) {
|
|
464
|
+
delete process.env.N8NAC_ENVIRONMENT;
|
|
465
|
+
}
|
|
466
|
+
else {
|
|
467
|
+
process.env.N8NAC_ENVIRONMENT = previousEnvironment;
|
|
88
468
|
}
|
|
89
469
|
}
|
|
90
|
-
this.printResult(result, options);
|
|
91
|
-
return result;
|
|
92
470
|
}
|
|
93
471
|
getEnvironmentWorkflowRoot(environment) {
|
|
94
|
-
|
|
95
|
-
|
|
472
|
+
const workflowsPath = environment.workflowsPath || environment.workflowDir;
|
|
473
|
+
if (!workflowsPath) {
|
|
474
|
+
throw new Error(`Environment "${environment.environmentName}" is missing a resolved workflowsPath. Check its API key, project, and instance identifier.`);
|
|
475
|
+
}
|
|
476
|
+
return path.resolve(workflowsPath);
|
|
477
|
+
}
|
|
478
|
+
assertTargetPath(targetPath, targetRoot, targetRootRealPath) {
|
|
479
|
+
if (!this.isPathInside(targetPath, targetRoot)) {
|
|
480
|
+
throw new Error('Resolved target path escapes the target environment workflowsPath.');
|
|
481
|
+
}
|
|
482
|
+
const targetParentRealPath = this.realpathExistingParent(path.dirname(targetPath));
|
|
483
|
+
if (fs.existsSync(targetRoot) && targetParentRealPath && !this.isPathInside(targetParentRealPath, targetRootRealPath)) {
|
|
484
|
+
throw new Error('Resolved target path escapes the target environment workflowsPath.');
|
|
96
485
|
}
|
|
97
|
-
return path.resolve(environment.workflowDir);
|
|
98
486
|
}
|
|
99
487
|
isPathInside(candidate, root) {
|
|
100
488
|
const relative = path.relative(path.resolve(root), path.resolve(candidate));
|
|
@@ -113,23 +501,133 @@ export class PromoteCommand {
|
|
|
113
501
|
}
|
|
114
502
|
return this.realpathExisting(current);
|
|
115
503
|
}
|
|
504
|
+
resolvePromotionConfigPath(configPath) {
|
|
505
|
+
return path.resolve(configPath || path.join(process.cwd(), 'n8nac-promotion.json'));
|
|
506
|
+
}
|
|
507
|
+
loadPromotionConfig(configPath) {
|
|
508
|
+
if (!fs.existsSync(configPath)) {
|
|
509
|
+
return { version: 1, routes: {} };
|
|
510
|
+
}
|
|
511
|
+
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
512
|
+
if (parsed.version !== 1 || !parsed.routes || typeof parsed.routes !== 'object') {
|
|
513
|
+
throw new Error(`Invalid promotion config: ${configPath}`);
|
|
514
|
+
}
|
|
515
|
+
return parsed;
|
|
516
|
+
}
|
|
517
|
+
savePromotionConfig(configPath, config) {
|
|
518
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
519
|
+
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
|
|
520
|
+
}
|
|
521
|
+
ensureRoute(config, routeKey) {
|
|
522
|
+
const route = config.routes[routeKey] ?? {};
|
|
523
|
+
route.bindings ??= {};
|
|
524
|
+
route.bindings.workflows ??= {};
|
|
525
|
+
route.bindings.credentials ??= {};
|
|
526
|
+
route.workflowOverrides ??= {};
|
|
527
|
+
route.credentialOverrides ??= {};
|
|
528
|
+
route.nameRules ??= [];
|
|
529
|
+
config.routes[routeKey] = route;
|
|
530
|
+
return route;
|
|
531
|
+
}
|
|
532
|
+
findOverride(overrides, ...keys) {
|
|
533
|
+
if (!overrides)
|
|
534
|
+
return undefined;
|
|
535
|
+
for (const key of keys) {
|
|
536
|
+
if (key && overrides[key])
|
|
537
|
+
return overrides[key];
|
|
538
|
+
}
|
|
539
|
+
return undefined;
|
|
540
|
+
}
|
|
541
|
+
resolveWorkflowOverride(override, indexes) {
|
|
542
|
+
if (!override)
|
|
543
|
+
return undefined;
|
|
544
|
+
if (override.targetId)
|
|
545
|
+
return indexes.targetWorkflowsById?.get(override.targetId);
|
|
546
|
+
if (override.targetName)
|
|
547
|
+
return unique(indexes.targetWorkflowsByName?.get(override.targetName));
|
|
548
|
+
return undefined;
|
|
549
|
+
}
|
|
550
|
+
applyNameRules(value, kind, route) {
|
|
551
|
+
let next = value;
|
|
552
|
+
for (const rule of route.nameRules ?? []) {
|
|
553
|
+
if (rule.kind && rule.kind !== kind)
|
|
554
|
+
continue;
|
|
555
|
+
try {
|
|
556
|
+
next = next.replace(new RegExp(rule.from), rule.to);
|
|
557
|
+
}
|
|
558
|
+
catch {
|
|
559
|
+
throw new Error(`Invalid ${rule.kind || 'promotion'} nameRule pattern "${rule.from}" for ${kind} promotion.`);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
return next;
|
|
563
|
+
}
|
|
564
|
+
buildResult(source, target, routeKey, configPath, workflows, options) {
|
|
565
|
+
const first = workflows[0];
|
|
566
|
+
return {
|
|
567
|
+
sourceEnvironmentId: source.environmentId,
|
|
568
|
+
sourceEnvironmentName: source.environmentName,
|
|
569
|
+
targetEnvironmentId: target.environmentId,
|
|
570
|
+
targetEnvironmentName: target.environmentName,
|
|
571
|
+
sourcePath: first?.sourcePath || '',
|
|
572
|
+
targetPath: first?.targetPath || '',
|
|
573
|
+
pushed: workflows.some((workflow) => workflow.status === 'pushed'),
|
|
574
|
+
workflowId: workflows.length === 1 ? workflows[0].targetWorkflowId : undefined,
|
|
575
|
+
credentialCheckCommand: workflows.length === 1 && workflows[0].targetWorkflowId
|
|
576
|
+
? `n8nac --env ${quoteShellArg(target.environmentName)} workflow credential-required ${quoteShellArg(workflows[0].targetWorkflowId)}`
|
|
577
|
+
: undefined,
|
|
578
|
+
dryRun: Boolean(options.dryRun),
|
|
579
|
+
routeKey,
|
|
580
|
+
configPath,
|
|
581
|
+
summary: {
|
|
582
|
+
planned: workflows.length,
|
|
583
|
+
created: workflows.filter((workflow) => workflow.action === 'create').length,
|
|
584
|
+
updated: workflows.filter((workflow) => workflow.action === 'update').length,
|
|
585
|
+
blocked: workflows.filter((workflow) => workflow.status === 'blocked' || workflow.problems.length > 0).length,
|
|
586
|
+
credentialMappings: workflows.reduce((count, workflow) => count + workflow.substitutions.filter((item) => item.kind === 'credential' && item.status === 'mapped').length, 0),
|
|
587
|
+
workflowMappings: workflows.reduce((count, workflow) => count + workflow.substitutions.filter((item) => item.kind === 'workflow' && item.status === 'mapped').length, 0),
|
|
588
|
+
},
|
|
589
|
+
workflows,
|
|
590
|
+
};
|
|
591
|
+
}
|
|
116
592
|
printResult(result, options) {
|
|
117
593
|
if (options.json) {
|
|
118
594
|
console.log(JSON.stringify(result, null, 2));
|
|
119
595
|
return;
|
|
120
596
|
}
|
|
121
597
|
const action = result.dryRun ? 'Would promote' : result.pushed ? 'Promoted and pushed' : 'Promoted';
|
|
122
|
-
|
|
123
|
-
console.log(chalk.
|
|
124
|
-
console.log(chalk.dim(`
|
|
125
|
-
|
|
126
|
-
|
|
598
|
+
const countLabel = result.workflows.length === 1 ? 'workflow' : 'workflows';
|
|
599
|
+
console.log(chalk.green(`✔ ${action} ${result.workflows.length} ${countLabel} from ${result.sourceEnvironmentName} to ${result.targetEnvironmentName}.`));
|
|
600
|
+
console.log(chalk.dim(` route: ${result.routeKey}`));
|
|
601
|
+
for (const workflow of result.workflows) {
|
|
602
|
+
const statusIcon = workflow.problems.length > 0 ? 'blocked' : workflow.action;
|
|
603
|
+
console.log(chalk.dim(` ${statusIcon}: ${workflow.sourcePath}`));
|
|
604
|
+
console.log(chalk.dim(` -> ${workflow.targetPath}`));
|
|
605
|
+
if (workflow.targetWorkflowId) {
|
|
606
|
+
console.log(chalk.dim(` remote workflow: ${workflow.targetWorkflowId}`));
|
|
607
|
+
}
|
|
608
|
+
for (const substitution of workflow.substitutions.filter((item) => item.kind !== 'metadata')) {
|
|
609
|
+
const from = substitution.fromName || substitution.fromId || 'unknown';
|
|
610
|
+
const to = substitution.toName || substitution.toId || 'pending';
|
|
611
|
+
console.log(chalk.dim(` ${substitution.kind}: ${from} -> ${to}`));
|
|
612
|
+
}
|
|
613
|
+
for (const problem of workflow.problems) {
|
|
614
|
+
console.log(chalk.yellow(` ${problem.kind}: ${problem.message}`));
|
|
615
|
+
}
|
|
127
616
|
}
|
|
128
617
|
if (result.credentialCheckCommand) {
|
|
129
618
|
console.log(chalk.yellow(` Check target credentials: ${result.credentialCheckCommand}`));
|
|
130
619
|
}
|
|
131
620
|
}
|
|
132
621
|
}
|
|
622
|
+
export async function compileWorkflowForPromotion(content) {
|
|
623
|
+
const parser = new TypeScriptParser();
|
|
624
|
+
const ast = await parser.parseCode(content);
|
|
625
|
+
const workflow = new WorkflowBuilder().build(ast);
|
|
626
|
+
if (Array.isArray(workflow.tags)) {
|
|
627
|
+
workflow.tags = workflow.tags.map((tag) => typeof tag === 'string' ? { id: tag, name: tag } : tag);
|
|
628
|
+
}
|
|
629
|
+
return workflow;
|
|
630
|
+
}
|
|
133
631
|
export function adaptWorkflowForPromotion(content, options = {}) {
|
|
134
632
|
let next = stripWorkflowDecoratorProperty(content, 'id');
|
|
135
633
|
next = stripWorkflowDecoratorProperty(next, 'projectId');
|
|
@@ -154,6 +652,57 @@ export function readWorkflowDecoratorProperty(content, property) {
|
|
|
154
652
|
const match = decorator.match(new RegExp(`${property}\\s*:\\s*(['"])(.*?)\\1`, 'm'));
|
|
155
653
|
return match?.[2];
|
|
156
654
|
}
|
|
655
|
+
function findWorkflowReferenceFields(parameters) {
|
|
656
|
+
const refs = [];
|
|
657
|
+
const visit = (value, currentPath) => {
|
|
658
|
+
if (!value || typeof value !== 'object')
|
|
659
|
+
return;
|
|
660
|
+
for (const [key, child] of Object.entries(value)) {
|
|
661
|
+
const nextPath = currentPath ? `${currentPath}.${key}` : key;
|
|
662
|
+
const normalizedKey = key.toLowerCase();
|
|
663
|
+
if ((normalizedKey === 'workflowid' || normalizedKey === 'workflow') && typeof child === 'string' && child.trim()) {
|
|
664
|
+
refs.push({
|
|
665
|
+
path: nextPath,
|
|
666
|
+
value: child,
|
|
667
|
+
set: (nextValue) => {
|
|
668
|
+
value[key] = nextValue;
|
|
669
|
+
},
|
|
670
|
+
});
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
if ((normalizedKey === 'workflowid' || normalizedKey === 'workflow') && child && typeof child === 'object') {
|
|
674
|
+
const objectValue = child.value;
|
|
675
|
+
if (typeof objectValue === 'string' && objectValue.trim()) {
|
|
676
|
+
refs.push({
|
|
677
|
+
path: `${nextPath}.value`,
|
|
678
|
+
value: objectValue,
|
|
679
|
+
set: (nextValue) => {
|
|
680
|
+
child.value = nextValue;
|
|
681
|
+
},
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
visit(child, nextPath);
|
|
686
|
+
}
|
|
687
|
+
};
|
|
688
|
+
visit(parameters, '');
|
|
689
|
+
return refs;
|
|
690
|
+
}
|
|
691
|
+
function cloneWorkflow(workflow) {
|
|
692
|
+
return JSON.parse(JSON.stringify(workflow));
|
|
693
|
+
}
|
|
694
|
+
function groupWorkflowsByName(workflows) {
|
|
695
|
+
const byName = new Map();
|
|
696
|
+
for (const workflow of workflows) {
|
|
697
|
+
const existing = byName.get(workflow.name) ?? [];
|
|
698
|
+
existing.push(workflow);
|
|
699
|
+
byName.set(workflow.name, existing);
|
|
700
|
+
}
|
|
701
|
+
return byName;
|
|
702
|
+
}
|
|
703
|
+
function unique(items) {
|
|
704
|
+
return items && items.length === 1 ? items[0] : undefined;
|
|
705
|
+
}
|
|
157
706
|
function stripWorkflowDecoratorProperty(content, property) {
|
|
158
707
|
const decoratorMatch = content.match(/@workflow\s*\(\s*\{[\s\S]*?\}\s*\)/);
|
|
159
708
|
if (!decoratorMatch || decoratorMatch.index === undefined)
|