n8nac 2.2.1 → 2.3.0-next.107

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.
@@ -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
- constructor(configService = new ConfigService()) {
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);
19
- const sourcePath = path.resolve(sourceWorkflowPath);
20
- if (!fs.existsSync(sourcePath)) {
21
- throw new Error(`Source workflow not found: ${sourcePath}`);
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);
22
45
  }
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}`);
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'}.`);
27
53
  }
28
- if (!sourcePath.endsWith('.workflow.ts')) {
29
- throw new Error('Promotion currently supports TypeScript workflow files (*.workflow.ts).');
54
+ if (options.dryRun) {
55
+ const result = this.buildResult(source, target, routeKey, configPath, planned, options);
56
+ this.printResult(result, options);
57
+ return result;
30
58
  }
31
- const relativePath = path.relative(sourceRoot, sourcePath);
32
- const targetPath = path.resolve(targetRoot, relativePath);
33
- if (!this.isPathInside(targetPath, targetRoot)) {
34
- throw new Error('Resolved target path escapes the target environment sync scope.');
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'}.`);
35
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);
36
79
  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.');
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
+ });
40
104
  }
41
- const targetExists = fs.existsSync(targetPath);
42
- if (targetExists && !this.isPathInside(this.realpathExisting(targetPath), targetRootRealPath)) {
43
- throw new Error('Resolved target path escapes the target environment sync scope.');
105
+ return sources;
106
+ }
107
+ resolveSourceWorkflowPath(sourceWorkflowPath, sourceRoot) {
108
+ const sourcePath = path.resolve(sourceRoot, sourceWorkflowPath);
109
+ if (!fs.existsSync(sourcePath)) {
110
+ throw new Error(`Source workflow not found: ${sourcePath}`);
44
111
  }
45
- if (targetExists && !options.overwrite && !options.dryRun) {
46
- throw new Error(`Target workflow already exists: ${targetPath}. Re-run with --overwrite to replace it.`);
112
+ if (!sourcePath.endsWith('.workflow.ts')) {
113
+ throw new Error('Promotion currently supports TypeScript workflow files (*.workflow.ts).');
47
114
  }
48
- const targetWorkflowId = targetExists ? readWorkflowDecoratorProperty(fs.readFileSync(targetPath, 'utf8'), 'id') : undefined;
49
- const adapted = adaptWorkflowForPromotion(fs.readFileSync(sourcePath, 'utf8'), {
50
- targetWorkflowId,
51
- targetProjectId: target.projectId,
52
- targetProjectName: target.projectName,
115
+ return sourcePath;
116
+ }
117
+ listSourceWorkflowPaths(sourceRoot) {
118
+ if (!fs.existsSync(sourceRoot)) {
119
+ throw new Error(`Source environment workflowsPath does not exist: ${sourceRoot}`);
120
+ }
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] : [];
53
129
  });
54
- const result = {
55
- sourceEnvironmentId: source.environmentId,
56
- sourceEnvironmentName: source.environmentName,
57
- targetEnvironmentId: target.environmentId,
58
- targetEnvironmentName: target.environmentName,
59
- sourcePath,
60
- targetPath,
61
- pushed: false,
62
- dryRun: Boolean(options.dryRun),
63
- };
64
- if (options.dryRun) {
65
- this.printResult(result, options);
66
- return result;
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';
67
139
  }
68
- fs.mkdirSync(path.dirname(targetPath), { recursive: true });
69
- fs.writeFileSync(targetPath, adapted, 'utf8');
70
- if (options.push !== false) {
71
- const previousEnvironment = process.env.N8NAC_ENVIRONMENT;
72
- try {
73
- process.env.N8NAC_ENVIRONMENT = target.environmentId;
74
- const workflowId = await new SyncCommand().pushOne(targetPath);
75
- result.workflowId = workflowId;
76
- result.pushed = Boolean(workflowId);
77
- result.credentialCheckCommand = workflowId
78
- ? `n8nac --env ${quoteShellArg(target.environmentName)} workflow credential-required ${quoteShellArg(workflowId)}`
79
- : undefined;
80
- }
81
- finally {
82
- if (previousEnvironment === undefined) {
83
- delete process.env.N8NAC_ENVIRONMENT;
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
+ }
166
+ }
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
+ });
183
+ }
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
- else {
86
- process.env.N8NAC_ENVIRONMENT = previousEnvironment;
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
- if (!environment.workflowDir) {
95
- throw new Error(`Environment "${environment.environmentName}" is missing a resolved workflow directory. Check its API key, project, and instance identifier.`);
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
- console.log(chalk.green(`✔ ${action} workflow from ${result.sourceEnvironmentName} to ${result.targetEnvironmentName}.`));
123
- console.log(chalk.dim(` ${result.sourcePath}`));
124
- console.log(chalk.dim(` -> ${result.targetPath}`));
125
- if (result.workflowId) {
126
- console.log(chalk.dim(` remote workflow: ${result.workflowId}`));
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)