cistack 3.2.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js CHANGED
@@ -18,6 +18,7 @@ const WorkflowGenerator = require('./generators/workflow');
18
18
  const DependabotGenerator = require('./generators/dependabot');
19
19
  const ReleaseGenerator = require('./generators/release');
20
20
  const ConfigLoader = require('./config/loader');
21
+ const combineWorkflows = require('./utils/workflow-combiner');
21
22
  const { ensureDir, writeFile, banner, smartMergeWorkflow } = require('./utils/helpers');
22
23
 
23
24
  const WorkflowAnalyzer = require('./analyzers/workflow');
@@ -42,17 +43,16 @@ class CIFlow {
42
43
  try {
43
44
  // ── 1. Load cistack.config.js ─────────────────────────────────────
44
45
  const configLoader = new ConfigLoader(this.projectPath);
45
- const userConfig = configLoader.load();
46
+ const userConfig = await configLoader.load();
46
47
  if (Object.keys(userConfig).length > 0) {
47
48
  spinner.info(chalk.cyan('cistack.config.js loaded'));
48
- spinner.start('Scanning project...');
49
- // Allow config to override outputDir
50
49
  if (userConfig.outputDir) {
51
50
  this.outputDir = path.join(this.projectPath, userConfig.outputDir);
52
51
  }
53
52
  }
54
53
 
55
54
  // ── 2. Analyse the codebase ───────────────────────────────────────
55
+ if (!spinner.isSpinning) spinner.start('Scanning project...');
56
56
  const analyzer = new CodebaseAnalyzer(this.projectPath, { verbose: this.verbose });
57
57
  const codebaseInfo = await analyzer.analyse();
58
58
  spinner.succeed(chalk.green('Project scanned'));
@@ -81,13 +81,22 @@ class CIFlow {
81
81
  frameworks,
82
82
  languages,
83
83
  testing,
84
+ releaseInfo,
84
85
  envVars,
85
86
  monorepoPackages,
87
+ lockFiles: codebaseInfo.lockFiles,
88
+ defaultBranch: codebaseInfo.defaultBranch,
89
+ currentBranch: codebaseInfo.currentBranch,
86
90
  _config: userConfig,
87
91
  });
88
92
 
89
93
  // ── 5. Print summary ───────────────────────────────────────────────
90
- this._printSummary(finalConfig, releaseInfo, envVars, monorepoPackages);
94
+ this._printSummary(
95
+ finalConfig,
96
+ finalConfig.releaseInfo || releaseInfo,
97
+ finalConfig.envVars || envVars,
98
+ finalConfig.monorepoPackages || monorepoPackages
99
+ );
91
100
 
92
101
  // ── 6. Optional interactive confirmation ──────────────────────────
93
102
  if (this.prompt) {
@@ -97,21 +106,26 @@ class CIFlow {
97
106
  // ── 7. Generate CI/CD workflow(s) ─────────────────────────────────
98
107
  spinner.start('Generating workflow(s)...');
99
108
  const generator = new WorkflowGenerator(finalConfig, this.projectPath);
100
- const workflows = generator.generate();
101
- spinner.succeed(chalk.green(`Generated ${workflows.length} CI workflow(s)`));
109
+ let workflows = generator.generate();
102
110
 
103
111
  // ── 8. Generate dependabot.yml ────────────────────────────────────
104
112
  const dependabotGen = new DependabotGenerator(codebaseInfo);
105
113
  const dependabotFile = dependabotGen.generate();
106
114
 
107
- // ── 9. Generate release.yml (if release tooling detected) ─────────
115
+ // ── 9. Generate release.yml (if release tooling detected or configured)
108
116
  let releaseWorkflow = null;
109
- if (releaseInfo) {
110
- const releaseGen = new ReleaseGenerator(releaseInfo, finalConfig, this.projectPath);
117
+ const combinedReleaseInfo = finalConfig.releaseInfo || releaseInfo;
118
+ if (combinedReleaseInfo) {
119
+ const releaseGen = new ReleaseGenerator(combinedReleaseInfo, finalConfig, this.projectPath);
111
120
  releaseWorkflow = releaseGen.generate();
112
121
  if (releaseWorkflow) workflows.push(releaseWorkflow);
113
122
  }
114
123
 
124
+ if (this._workflowLayout(finalConfig) !== 'split') {
125
+ workflows = [combineWorkflows(workflows, { config: finalConfig, releaseInfo: combinedReleaseInfo })];
126
+ }
127
+ spinner.succeed(chalk.green(`Generated ${workflows.length} workflow file(s)`));
128
+
115
129
  // ── 10. Write files ────────────────────────────────────────────────
116
130
  if (this.dryRun) {
117
131
  this._dryRunPrint(workflows, dependabotFile);
@@ -244,6 +258,11 @@ class CIFlow {
244
258
  console.log('');
245
259
  }
246
260
 
261
+ _workflowLayout(config) {
262
+ const layout = config && config._config && config._config.workflowLayout;
263
+ return layout === 'split' ? 'split' : 'single';
264
+ }
265
+
247
266
  async _interactiveConfirm(config) {
248
267
  const { confirmed } = await inquirer.prompt([
249
268
  {
@@ -388,19 +407,15 @@ class CIFlow {
388
407
  ensureDir(githubDir);
389
408
 
390
409
  if (exists && !this.force) {
410
+ // dependabot.yml has a fixed schema, simpler to just overwrite or keep if identical
391
411
  const existing = fs.readFileSync(filePath, 'utf8');
392
- const { content: merged, changes } = smartMergeWorkflow(existing, dependabotFile.content);
393
-
394
- if (changes.length === 0) {
412
+ if (existing.trim() === dependabotFile.content.trim()) {
395
413
  console.log(chalk.dim(` ○ No changes: dependabot.yml`));
396
414
  return;
397
415
  }
398
-
399
- writeFile(filePath, merged);
400
- console.log(chalk.yellow(` ↻ Smart-merged: dependabot.yml`));
401
- for (const c of changes) {
402
- console.log(chalk.dim(` • ${c}`));
403
- }
416
+
417
+ writeFile(filePath, dependabotFile.content);
418
+ console.log(chalk.yellow(` ↻ Updated: dependabot.yml (schema mismatch for smart-merge)`));
404
419
  } else {
405
420
  writeFile(filePath, dependabotFile.content);
406
421
  console.log(chalk.green(` ✔ Written: .github/dependabot.yml`));
@@ -129,16 +129,17 @@ function _mergeJob(existJob, genJob, jobId) {
129
129
  }
130
130
  }
131
131
 
132
- // Merge steps by name
132
+ // Merge steps
133
133
  if (genJob.steps) {
134
- const existStepsByName = {};
135
- for (const s of (existJob.steps || [])) {
136
- if (s.name) existStepsByName[s.name] = s;
137
- }
138
-
139
134
  const mergedSteps = [];
140
135
  for (const genStep of genJob.steps) {
141
- const existStep = existStepsByName[genStep.name];
136
+ // Find matches by name, uses, or id
137
+ const existStep = (existJob.steps || []).find(s =>
138
+ (genStep.name && s.name === genStep.name) ||
139
+ (genStep.id && s.id === genStep.id) ||
140
+ (genStep.uses && s.uses === genStep.uses)
141
+ );
142
+
142
143
  if (!existStep) {
143
144
  mergedSteps.push(genStep);
144
145
  jobChanges.push(` job "${jobId}" → added step "${genStep.name}"`);
@@ -153,6 +154,19 @@ function _mergeJob(existJob, genJob, jobId) {
153
154
  }
154
155
  }
155
156
  }
157
+ // Append any existing steps that were NOT matched by a generated step
158
+ // This ensures user customizations are preserved.
159
+ for (const existStep of (existJob.steps || [])) {
160
+ const isMatched = genJob.steps.some((genStep) =>
161
+ (genStep.name && existStep.name === genStep.name) ||
162
+ (genStep.id && existStep.id === genStep.id) ||
163
+ (genStep.uses && existStep.uses === genStep.uses)
164
+ );
165
+ if (!isMatched) {
166
+ mergedSteps.push(existStep);
167
+ }
168
+ }
169
+
156
170
  merged.steps = mergedSteps;
157
171
  }
158
172
 
@@ -0,0 +1,238 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const yaml = require('js-yaml');
5
+ const { version } = require('../../package.json');
6
+
7
+ function stripHeader(content) {
8
+ return content.replace(/^(?:#[^\n]*\n)+\n?/, '');
9
+ }
10
+
11
+ function isPlainObject(value) {
12
+ return !!value && typeof value === 'object' && !Array.isArray(value);
13
+ }
14
+
15
+ function dedupeArray(values) {
16
+ const seen = new Set();
17
+ const result = [];
18
+
19
+ for (const value of values) {
20
+ const key = isPlainObject(value) || Array.isArray(value)
21
+ ? JSON.stringify(value)
22
+ : String(value);
23
+ if (seen.has(key)) continue;
24
+ seen.add(key);
25
+ result.push(value);
26
+ }
27
+
28
+ return result;
29
+ }
30
+
31
+ function mergeDeep(target, source) {
32
+ if (Array.isArray(target) || Array.isArray(source)) {
33
+ return dedupeArray([...(target || []), ...(source || [])]);
34
+ }
35
+ if (isPlainObject(target) && isPlainObject(source)) {
36
+ const merged = { ...target };
37
+ for (const [key, value] of Object.entries(source)) {
38
+ merged[key] = key in merged ? mergeDeep(merged[key], value) : value;
39
+ }
40
+ return merged;
41
+ }
42
+ return source === undefined ? target : source;
43
+ }
44
+
45
+ function mergePermissions(target, source) {
46
+ const rank = { none: 0, read: 1, write: 2 };
47
+ const merged = { ...(target || {}) };
48
+
49
+ for (const [key, value] of Object.entries(source || {})) {
50
+ const current = merged[key];
51
+ if (!current) {
52
+ merged[key] = value;
53
+ continue;
54
+ }
55
+ merged[key] = (rank[value] || 0) > (rank[current] || 0) ? value : current;
56
+ }
57
+
58
+ return merged;
59
+ }
60
+
61
+ function prefixJobMap(jobs, prefix) {
62
+ const mapping = {};
63
+ for (const jobId of Object.keys(jobs || {})) {
64
+ mapping[jobId] = `${prefix}_${jobId}`;
65
+ }
66
+
67
+ const prefixed = {};
68
+ for (const [jobId, job] of Object.entries(jobs || {})) {
69
+ const nextJob = JSON.parse(JSON.stringify(job));
70
+ if (typeof nextJob.needs === 'string') {
71
+ nextJob.needs = mapping[nextJob.needs] || nextJob.needs;
72
+ } else if (Array.isArray(nextJob.needs)) {
73
+ nextJob.needs = nextJob.needs.map((need) => mapping[need] || need);
74
+ }
75
+ prefixed[mapping[jobId]] = nextJob;
76
+ }
77
+
78
+ return prefixed;
79
+ }
80
+
81
+ function unwrapExpression(expr) {
82
+ if (typeof expr !== 'string') return '';
83
+ return expr.trim().replace(/^\${{\s*/, '').replace(/\s*}}$/, '');
84
+ }
85
+
86
+ function quote(value) {
87
+ return String(value).replace(/'/g, "\\'");
88
+ }
89
+
90
+ function buildBranchCondition(branches, refName) {
91
+ if (!Array.isArray(branches) || branches.length === 0) return '';
92
+ return branches
93
+ .map((branch) => `${refName} == '${quote(branch)}'`)
94
+ .join(' || ');
95
+ }
96
+
97
+ function buildTriggerCondition(onConfig) {
98
+ if (!onConfig) return '';
99
+
100
+ if (typeof onConfig === 'string') {
101
+ return `github.event_name == '${quote(onConfig)}'`;
102
+ }
103
+
104
+ if (Array.isArray(onConfig)) {
105
+ return onConfig
106
+ .map((eventName) => `github.event_name == '${quote(eventName)}'`)
107
+ .join(' || ');
108
+ }
109
+
110
+ const clauses = [];
111
+
112
+ for (const [eventName, eventConfig] of Object.entries(onConfig)) {
113
+ if (eventName === 'push') {
114
+ const branchExpr = buildBranchCondition(eventConfig && eventConfig.branches, 'github.ref_name');
115
+ clauses.push(branchExpr ? `(github.event_name == 'push' && (${branchExpr}))` : "github.event_name == 'push'");
116
+ continue;
117
+ }
118
+
119
+ if (eventName === 'pull_request') {
120
+ const branchExpr = buildBranchCondition(eventConfig && eventConfig.branches, 'github.base_ref');
121
+ clauses.push(branchExpr ? `(github.event_name == 'pull_request' && (${branchExpr}))` : "github.event_name == 'pull_request'");
122
+ continue;
123
+ }
124
+
125
+ if (eventName === 'workflow_dispatch') {
126
+ clauses.push("github.event_name == 'workflow_dispatch'");
127
+ continue;
128
+ }
129
+
130
+ if (eventName === 'schedule') {
131
+ clauses.push("github.event_name == 'schedule'");
132
+ continue;
133
+ }
134
+
135
+ clauses.push(`github.event_name == '${quote(eventName)}'`);
136
+ }
137
+
138
+ return clauses.join(' || ');
139
+ }
140
+
141
+ function applyTriggerCondition(job, triggerCondition) {
142
+ if (!triggerCondition) return job;
143
+
144
+ const nextJob = { ...job };
145
+ const existingIf = unwrapExpression(nextJob.if);
146
+ nextJob.if = existingIf
147
+ ? `(${triggerCondition}) && (${existingIf})`
148
+ : triggerCondition;
149
+ return nextJob;
150
+ }
151
+
152
+ function collectRequiredSecrets(config, releaseInfo) {
153
+ const secrets = new Set();
154
+
155
+ for (const hosting of config.hosting || []) {
156
+ for (const secret of hosting.secrets || []) {
157
+ secrets.add(secret);
158
+ }
159
+ }
160
+
161
+ for (const secret of (config.envVars && config.envVars.secrets) || []) {
162
+ secrets.add(secret);
163
+ }
164
+
165
+ if (releaseInfo && releaseInfo.requiresNpmToken) {
166
+ secrets.add('NPM_TOKEN');
167
+ }
168
+
169
+ return [...secrets];
170
+ }
171
+
172
+ function buildHeader(sections, config, releaseInfo) {
173
+ const sectionList = dedupeArray(sections).join(', ');
174
+ const requiredSecrets = collectRequiredSecrets(config, releaseInfo);
175
+ const secretsDoc = requiredSecrets.length > 0
176
+ ? `# Required secrets: ${requiredSecrets.join(', ')}\n# Add these at: Settings → Secrets and Variables → Actions\n`
177
+ : '';
178
+
179
+ return (
180
+ `# Generated by cistack v${version} — https://github.com/cistack\n` +
181
+ `# Unified Pipeline: ${sectionList}\n` +
182
+ `${secretsDoc}` +
183
+ `# Dependabot remains in .github/dependabot.yml\n\n`
184
+ );
185
+ }
186
+
187
+ function combineWorkflows(workflows, options = {}) {
188
+ const config = options.config || {};
189
+ const releaseInfo = options.releaseInfo || null;
190
+ const combined = {
191
+ name: 'Pipeline',
192
+ on: {},
193
+ concurrency: {
194
+ group: '${{ github.workflow }}-${{ github.ref }}',
195
+ 'cancel-in-progress': true,
196
+ },
197
+ jobs: {},
198
+ };
199
+ const sections = [];
200
+
201
+ for (const workflow of workflows.filter(Boolean)) {
202
+ const parsed = yaml.load(stripHeader(workflow.content));
203
+ const triggerCondition = buildTriggerCondition(parsed.on || {});
204
+ const prefix = path.basename(workflow.filename, path.extname(workflow.filename))
205
+ .replace(/[^a-z0-9]+/gi, '_')
206
+ .replace(/^_+|_+$/g, '')
207
+ .toLowerCase();
208
+
209
+ sections.push(prefix);
210
+ combined.on = mergeDeep(combined.on, parsed.on || {});
211
+ if (parsed.env) {
212
+ combined.env = mergeDeep(combined.env || {}, parsed.env);
213
+ }
214
+ if (parsed.permissions) {
215
+ combined.permissions = mergePermissions(combined.permissions, parsed.permissions);
216
+ }
217
+
218
+ const prefixedJobs = prefixJobMap(parsed.jobs, prefix);
219
+ for (const [jobId, job] of Object.entries(prefixedJobs)) {
220
+ combined.jobs[jobId] = applyTriggerCondition(job, triggerCondition);
221
+ }
222
+ }
223
+
224
+ const raw = yaml.dump(combined, {
225
+ indent: 2,
226
+ lineWidth: 120,
227
+ quotingType: "'",
228
+ forceQuotes: false,
229
+ noRefs: true,
230
+ });
231
+
232
+ return {
233
+ filename: 'pipeline.yml',
234
+ content: buildHeader(sections, config, releaseInfo) + raw,
235
+ };
236
+ }
237
+
238
+ module.exports = combineWorkflows;