cistack 1.0.0 → 2.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/bin/ciflow.js CHANGED
@@ -9,7 +9,7 @@ const CIFlow = require('../src/index');
9
9
  program
10
10
  .name('cistack')
11
11
  .description('Generate GitHub Actions CI/CD pipelines by analysing your codebase')
12
- .version('1.0.0');
12
+ .version('2.0.0');
13
13
 
14
14
  program
15
15
  .command('generate', { isDefault: true })
@@ -17,19 +17,78 @@ program
17
17
  .option('-p, --path <dir>', 'Path to the project root', process.cwd())
18
18
  .option('-o, --output <dir>', 'Output directory for workflow files', '.github/workflows')
19
19
  .option('--dry-run', 'Print the generated YAML without writing files')
20
- .option('--force', 'Overwrite existing workflow files without prompting')
20
+ .option('--force', 'Overwrite existing workflow files without smart-merge')
21
21
  .option('--no-prompt', 'Skip interactive prompts and use detected settings')
22
22
  .option('--verbose', 'Show detailed analysis output')
23
23
  .action(async (options) => {
24
24
  const ciflow = new CIFlow({
25
25
  projectPath: path.resolve(options.path),
26
- outputDir: options.output,
27
- dryRun: options.dryRun,
28
- force: options.force,
29
- prompt: options.prompt,
30
- verbose: options.verbose,
26
+ outputDir: options.output,
27
+ dryRun: options.dryRun,
28
+ force: options.force,
29
+ prompt: options.prompt,
30
+ verbose: options.verbose,
31
31
  });
32
32
  await ciflow.run();
33
33
  });
34
34
 
35
+ program
36
+ .command('init')
37
+ .description('Create a starter cistack.config.js in the current directory')
38
+ .option('-p, --path <dir>', 'Path to the project root', process.cwd())
39
+ .action(async (options) => {
40
+ const fs = require('fs');
41
+ const resolvedPath = path.resolve(options.path);
42
+ const configPath = path.join(resolvedPath, 'cistack.config.js');
43
+
44
+ // Ensure the target directory exists
45
+ if (!fs.existsSync(resolvedPath)) {
46
+ fs.mkdirSync(resolvedPath, { recursive: true });
47
+ }
48
+
49
+ if (fs.existsSync(configPath)) {
50
+ console.error('cistack.config.js already exists. Delete it first or edit it manually.');
51
+ process.exit(1);
52
+ }
53
+
54
+ const template = `// cistack.config.js
55
+ // Override auto-detected settings for this project.
56
+ // All fields are optional — omit what you don't need.
57
+
58
+ /** @type {import('cistack').Config} */
59
+ module.exports = {
60
+ // nodeVersion: '20', // Override detected Node.js version
61
+ // packageManager: 'pnpm', // 'npm' | 'yarn' | 'pnpm' | 'bun'
62
+ // hosting: ['Firebase'], // Force a specific hosting provider
63
+ // branches: ['main', 'staging'], // CI branches (default: main, master, develop)
64
+ // outputDir: '.github/workflows', // Where to write workflow files
65
+
66
+ // cache: {
67
+ // npm: true, // enabled by default
68
+ // pip: true,
69
+ // cargo: true,
70
+ // maven: true,
71
+ // gradle: true,
72
+ // go: true,
73
+ // composer: true,
74
+ // },
75
+
76
+ // monorepo: {
77
+ // perPackage: true, // Generate one ci-<name>.yml per workspace
78
+ // },
79
+
80
+ // release: {
81
+ // tool: 'semantic-release', // override release tool detection
82
+ // },
83
+
84
+ // secrets: ['MY_EXTRA_SECRET'], // Document additional secrets in workflow comments
85
+ };
86
+ `;
87
+
88
+ fs.writeFileSync(configPath, template, 'utf8');
89
+ const chalk = require('chalk');
90
+ console.log(chalk.green(`✔ Created cistack.config.js at ${configPath}`));
91
+ console.log(chalk.dim(' Edit the file to override detected settings.'));
92
+ });
93
+
35
94
  program.parse(process.argv);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cistack",
3
- "version": "1.0.0",
3
+ "version": "2.0.0",
4
4
  "description": "Automatically generate GitHub Actions CI/CD pipelines by analysing your codebase",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node bin/ciflow.js",
11
- "test": "node bin/ciflow.js --dry-run"
11
+ "test": "node bin/ciflow.js --dry-run --no-prompt"
12
12
  },
13
13
  "keywords": [
14
14
  "github-actions",
@@ -18,7 +18,14 @@
18
18
  "devops",
19
19
  "firebase",
20
20
  "vercel",
21
- "netlify"
21
+ "netlify",
22
+ "monorepo",
23
+ "dependabot",
24
+ "semantic-release",
25
+ "changesets",
26
+ "caching",
27
+ "turborepo",
28
+ "nx"
22
29
  ],
23
30
  "author": "",
24
31
  "license": "MIT",
@@ -0,0 +1,124 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { globSync } = require('glob');
6
+
7
+ /**
8
+ * Resolves workspace packages from monorepo config files.
9
+ *
10
+ * Supported:
11
+ * - package.json#workspaces (npm/yarn workspaces array or { packages: [] })
12
+ * - pnpm-workspace.yaml
13
+ * - turbo.json (uses package.json workspaces in conjunction)
14
+ * - nx.json (discovers apps/* and libs/*)
15
+ * - lerna.json (uses lerna packages array)
16
+ *
17
+ * Returns: Array<{ name: string, relativePath: string, absolutePath: string, packageJson: object|null }>
18
+ */
19
+ class MonorepoAnalyzer {
20
+ constructor(projectPath, codebaseInfo) {
21
+ this.root = projectPath;
22
+ this.info = codebaseInfo;
23
+ this.pkg = codebaseInfo.packageJson || {};
24
+ }
25
+
26
+ async analyze() {
27
+ if (!this.info.hasMonorepo) return [];
28
+
29
+ const workspaceGlobs = this._collectWorkspaceGlobs();
30
+ if (workspaceGlobs.length === 0) return [];
31
+
32
+ const packages = [];
33
+ const seen = new Set();
34
+
35
+ for (const pattern of workspaceGlobs) {
36
+ // Each glob glob like 'packages/*' → expand to actual dirs
37
+ const matches = globSync(pattern, {
38
+ cwd: this.root,
39
+ onlyDirectories: true,
40
+ absolute: false,
41
+ ignore: ['node_modules/**', '**/node_modules/**'],
42
+ });
43
+
44
+ for (const relDir of matches) {
45
+ if (seen.has(relDir)) continue;
46
+ seen.add(relDir);
47
+
48
+ const absPath = path.join(this.root, relDir);
49
+ const pkgJsonPath = path.join(absPath, 'package.json');
50
+ let pkgJson = null;
51
+
52
+ if (fs.existsSync(pkgJsonPath)) {
53
+ try {
54
+ pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
55
+ } catch (_) {}
56
+ }
57
+
58
+ const name = (pkgJson && pkgJson.name) || path.basename(relDir);
59
+
60
+ packages.push({
61
+ name,
62
+ relativePath: relDir,
63
+ absolutePath: absPath,
64
+ packageJson: pkgJson,
65
+ });
66
+ }
67
+ }
68
+
69
+ return packages;
70
+ }
71
+
72
+ _collectWorkspaceGlobs() {
73
+ const globs = [];
74
+
75
+ // 1. package.json workspaces
76
+ if (this.pkg.workspaces) {
77
+ const ws = this.pkg.workspaces;
78
+ if (Array.isArray(ws)) {
79
+ globs.push(...ws);
80
+ } else if (ws.packages) {
81
+ globs.push(...ws.packages);
82
+ }
83
+ }
84
+
85
+ // 2. pnpm-workspace.yaml
86
+ const pnpmWsPath = path.join(this.root, 'pnpm-workspace.yaml');
87
+ if (fs.existsSync(pnpmWsPath)) {
88
+ try {
89
+ const yaml = require('js-yaml');
90
+ const parsed = yaml.load(fs.readFileSync(pnpmWsPath, 'utf8'));
91
+ if (parsed && parsed.packages) {
92
+ globs.push(...parsed.packages);
93
+ }
94
+ } catch (_) {}
95
+ }
96
+
97
+ // 3. lerna.json
98
+ const lernaPath = path.join(this.root, 'lerna.json');
99
+ if (fs.existsSync(lernaPath)) {
100
+ try {
101
+ const lerna = JSON.parse(fs.readFileSync(lernaPath, 'utf8'));
102
+ if (lerna.packages) globs.push(...lerna.packages);
103
+ } catch (_) {}
104
+ }
105
+
106
+ // 4. nx.json — default convention: apps/* + libs/*
107
+ const nxPath = path.join(this.root, 'nx.json');
108
+ if (fs.existsSync(nxPath) && globs.length === 0) {
109
+ globs.push('apps/*', 'libs/*', 'packages/*');
110
+ }
111
+
112
+ // 5. turbo.json — uses root package.json workspaces (already handled above)
113
+ // If none found yet, use convention
114
+ const turboPath = path.join(this.root, 'turbo.json');
115
+ if (fs.existsSync(turboPath) && globs.length === 0) {
116
+ globs.push('apps/*', 'packages/*');
117
+ }
118
+
119
+ // Deduplicate
120
+ return [...new Set(globs)];
121
+ }
122
+ }
123
+
124
+ module.exports = MonorepoAnalyzer;
@@ -0,0 +1,123 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ /**
7
+ * Loads cistack.config.js (or .cjs / .mjs) from the project root.
8
+ * Returns an empty object if no config file is found.
9
+ *
10
+ * Supported keys:
11
+ * nodeVersion – override detected Node version e.g. '18'
12
+ * packageManager – override detected PM: 'npm'|'yarn'|'pnpm'|'bun'
13
+ * hosting – array of hosting names to force e.g. ['Firebase']
14
+ * branches – branches to run CI on e.g. ['main', 'staging']
15
+ * cache – { npm: bool, cargo: bool, pip: bool, ... } enable/disable caches
16
+ * monorepo – { perPackage: bool } generate one file per workspace
17
+ * release – { tool: 'semantic-release'|'changesets'|'standard-version'|'release-it' }
18
+ * secrets – extra secret names to document in workflow comments
19
+ * outputDir – override default '.github/workflows'
20
+ */
21
+ class ConfigLoader {
22
+ constructor(projectPath) {
23
+ this.projectPath = projectPath;
24
+ }
25
+
26
+ load() {
27
+ const candidates = [
28
+ 'cistack.config.js',
29
+ 'cistack.config.cjs',
30
+ 'cistack.config.mjs',
31
+ ];
32
+
33
+ for (const candidate of candidates) {
34
+ const fullPath = path.join(this.projectPath, candidate);
35
+ if (fs.existsSync(fullPath)) {
36
+ try {
37
+ // Clear require cache so hot-reloads work in watch mode
38
+ delete require.cache[require.resolve(fullPath)];
39
+ const cfg = require(fullPath);
40
+ // Handle both `module.exports = {}` and `export default {}`
41
+ const resolved = cfg && cfg.__esModule ? cfg.default : cfg;
42
+ if (resolved && typeof resolved === 'object') {
43
+ return resolved;
44
+ }
45
+ } catch (err) {
46
+ console.warn(`[cistack] Warning: could not load ${candidate}: ${err.message}`);
47
+ }
48
+ }
49
+ }
50
+
51
+ return {};
52
+ }
53
+
54
+ /**
55
+ * Deep-merge config file settings into detected settings.
56
+ * Config file always wins on scalar values; arrays overwrite entirely.
57
+ */
58
+ static merge(detected, override) {
59
+ if (!override || typeof override !== 'object') return detected;
60
+
61
+ const result = { ...detected };
62
+
63
+ for (const [key, val] of Object.entries(override)) {
64
+ if (val === null || val === undefined) continue;
65
+
66
+ if (Array.isArray(val)) {
67
+ result[key] = val; // arrays overwrite
68
+ } else if (typeof val === 'object' && !Array.isArray(detected[key])) {
69
+ result[key] = { ...(detected[key] || {}), ...val };
70
+ } else {
71
+ result[key] = val;
72
+ }
73
+ }
74
+
75
+ return result;
76
+ }
77
+
78
+ /**
79
+ * Apply config file overrides onto the full detected stack.
80
+ *
81
+ * @param {object} cfg - raw cistack.config.js export
82
+ * @param {object} detected - { hosting, frameworks, languages, testing }
83
+ * @returns {object} - merged config ready for the generator
84
+ */
85
+ static applyToStack(cfg, detected) {
86
+ if (!cfg || Object.keys(cfg).length === 0) return detected;
87
+
88
+ const result = { ...detected };
89
+
90
+ // Override primary language settings
91
+ if (cfg.nodeVersion && result.languages && result.languages.length > 0) {
92
+ result.languages = result.languages.map((l, i) =>
93
+ i === 0 && (l.name === 'JavaScript' || l.name === 'TypeScript')
94
+ ? { ...l, nodeVersion: String(cfg.nodeVersion) }
95
+ : l
96
+ );
97
+ }
98
+
99
+ if (cfg.packageManager && result.languages && result.languages.length > 0) {
100
+ result.languages = result.languages.map((l, i) =>
101
+ i === 0 ? { ...l, packageManager: cfg.packageManager } : l
102
+ );
103
+ }
104
+
105
+ // Override hosting
106
+ if (cfg.hosting && Array.isArray(cfg.hosting)) {
107
+ result.hosting = cfg.hosting.map((name) => ({
108
+ name,
109
+ confidence: 1.0,
110
+ manual: true,
111
+ secrets: [],
112
+ notes: ['set via cistack.config.js'],
113
+ }));
114
+ }
115
+
116
+ // Pass through raw extras for generators to consume
117
+ result._config = cfg;
118
+
119
+ return result;
120
+ }
121
+ }
122
+
123
+ module.exports = ConfigLoader;
@@ -0,0 +1,69 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ /**
7
+ * Detects environment variables documented in .env.example or .env.sample.
8
+ *
9
+ * Returns:
10
+ * {
11
+ * secrets: string[], – keys that look like secrets (TOKEN, KEY, SECRET, PASSWORD, PASS)
12
+ * public: string[], – other public env vars
13
+ * all: string[], – full list in file order
14
+ * sourceFile: string, – which file was read
15
+ * }
16
+ */
17
+ class EnvDetector {
18
+ constructor(projectPath, codebaseInfo) {
19
+ this.root = projectPath;
20
+ this.info = codebaseInfo;
21
+ }
22
+
23
+ async detect() {
24
+ const candidates = ['.env.example', '.env.sample', '.env.template', '.env.defaults'];
25
+
26
+ for (const candidate of candidates) {
27
+ const fullPath = path.join(this.root, candidate);
28
+ if (fs.existsSync(fullPath)) {
29
+ const content = fs.readFileSync(fullPath, 'utf8');
30
+ return this._parse(content, candidate);
31
+ }
32
+ }
33
+
34
+ return { secrets: [], public: [], all: [], sourceFile: null };
35
+ }
36
+
37
+ _parse(content, sourceFile) {
38
+ const all = [];
39
+ const secrets = [];
40
+ const publicVars = [];
41
+
42
+ const SECRET_PATTERN = /SECRET|TOKEN|KEY|PASSWORD|PASS|PRIVATE|AUTH|CREDENTIAL|CERT|PEM/i;
43
+
44
+ for (const rawLine of content.split('\n')) {
45
+ const line = rawLine.trim();
46
+
47
+ // Skip comments and blank lines
48
+ if (!line || line.startsWith('#')) continue;
49
+
50
+ // Match KEY=value or KEY= or just KEY
51
+ const match = line.match(/^([A-Z_][A-Z0-9_]*)\s*(?:=.*)?$/i);
52
+ if (!match) continue;
53
+
54
+ const key = match[1].toUpperCase();
55
+ if (all.includes(key)) continue; // de-dupe
56
+
57
+ all.push(key);
58
+ if (SECRET_PATTERN.test(key)) {
59
+ secrets.push(key);
60
+ } else {
61
+ publicVars.push(key);
62
+ }
63
+ }
64
+
65
+ return { secrets, public: publicVars, all, sourceFile };
66
+ }
67
+ }
68
+
69
+ module.exports = EnvDetector;
@@ -0,0 +1,124 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ /**
7
+ * Detects the release tooling used in a project.
8
+ *
9
+ * Checks (in order of priority):
10
+ * 1. semantic-release
11
+ * 2. @changesets/cli
12
+ * 3. release-it
13
+ * 4. standard-version
14
+ *
15
+ * Returns: { tool: string, config: object, publishToNpm: bool } or null
16
+ */
17
+ class ReleaseDetector {
18
+ constructor(projectPath, codebaseInfo) {
19
+ this.root = projectPath;
20
+ this.info = codebaseInfo;
21
+ this.pkg = codebaseInfo.packageJson || {};
22
+ this.deps = {
23
+ ...(this.pkg.dependencies || {}),
24
+ ...(this.pkg.devDependencies || {}),
25
+ };
26
+ this.scripts = this.pkg.scripts || {};
27
+ }
28
+
29
+ async detect() {
30
+ // --- semantic-release ---
31
+ if (this.deps['semantic-release']) {
32
+ const config = this._loadSemanticReleaseConfig();
33
+ const plugins = config.plugins || ['@semantic-release/commit-analyzer', '@semantic-release/release-notes-generator', '@semantic-release/github'];
34
+ return {
35
+ tool: 'semantic-release',
36
+ command: 'npx semantic-release',
37
+ config,
38
+ plugins,
39
+ publishToNpm: plugins.some((p) => (typeof p === 'string' ? p : p[0]) === '@semantic-release/npm'),
40
+ requiresNpmToken: plugins.some((p) => (typeof p === 'string' ? p : p[0]) === '@semantic-release/npm'),
41
+ };
42
+ }
43
+
44
+ // --- changesets ---
45
+ if (this.deps['@changesets/cli']) {
46
+ const publishScript = this.scripts['release'] || this.scripts['publish'];
47
+ return {
48
+ tool: 'changesets',
49
+ command: 'npx changeset publish',
50
+ publishToNpm: !!(publishScript && publishScript.includes('publish')),
51
+ requiresNpmToken: true,
52
+ };
53
+ }
54
+
55
+ // --- release-it ---
56
+ if (this.deps['release-it']) {
57
+ const config = this._loadReleaseItConfig();
58
+ return {
59
+ tool: 'release-it',
60
+ command: 'npx release-it --ci',
61
+ config,
62
+ publishToNpm: !!(config && config.npm && config.npm.publish !== false),
63
+ requiresNpmToken: true,
64
+ };
65
+ }
66
+
67
+ // --- standard-version ---
68
+ if (this.deps['standard-version']) {
69
+ return {
70
+ tool: 'standard-version',
71
+ command: 'npx standard-version',
72
+ publishToNpm: false,
73
+ requiresNpmToken: false,
74
+ };
75
+ }
76
+
77
+ // --- fallback: check scripts ---
78
+ const releaseScript = this.scripts['release'] || this.scripts['version'];
79
+ if (releaseScript) {
80
+ return {
81
+ tool: 'custom',
82
+ command: 'npm run release',
83
+ publishToNpm: releaseScript.includes('publish'),
84
+ requiresNpmToken: releaseScript.includes('publish'),
85
+ };
86
+ }
87
+
88
+ return null;
89
+ }
90
+
91
+ _loadSemanticReleaseConfig() {
92
+ // Try .releaserc, .releaserc.json, .releaserc.js, package.json#release
93
+ const candidates = ['.releaserc', '.releaserc.json', '.releaserc.js', '.releaserc.yaml'];
94
+ for (const c of candidates) {
95
+ const p = path.join(this.root, c);
96
+ if (fs.existsSync(p)) {
97
+ try {
98
+ const raw = fs.readFileSync(p, 'utf8');
99
+ if (c.endsWith('.js')) return require(p);
100
+ return JSON.parse(raw);
101
+ } catch (_) {}
102
+ }
103
+ }
104
+ if (this.pkg.release) return this.pkg.release;
105
+ return {};
106
+ }
107
+
108
+ _loadReleaseItConfig() {
109
+ const candidates = ['.release-it.json', '.release-it.js', '.release-it.yaml'];
110
+ for (const c of candidates) {
111
+ const p = path.join(this.root, c);
112
+ if (fs.existsSync(p)) {
113
+ try {
114
+ const raw = fs.readFileSync(p, 'utf8');
115
+ return JSON.parse(raw);
116
+ } catch (_) {}
117
+ }
118
+ }
119
+ if (this.pkg['release-it']) return this.pkg['release-it'];
120
+ return {};
121
+ }
122
+ }
123
+
124
+ module.exports = ReleaseDetector;