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.
@@ -20,7 +20,10 @@ class WorkflowGenerator {
20
20
  this.projectPath = projectPath;
21
21
  this.envVars = config.envVars || { secrets: [], public: [], all: [], sourceFile: null };
22
22
  this.monorepoPackages = config.monorepoPackages || [];
23
+ this.lockFiles = new Set(config.lockFiles || []);
23
24
  this.extraConfig = config._config || {}; // raw cistack.config.js
25
+ this.defaultBranch = config.defaultBranch || config.currentBranch || null;
26
+ this.workflowLayout = this.extraConfig.workflowLayout === 'split' ? 'split' : 'single';
24
27
 
25
28
  // Convenient accessors
26
29
  this.primaryLang = this.languages[0] || { name: 'JavaScript', packageManager: 'npm', nodeVersion: '20' };
@@ -29,26 +32,64 @@ class WorkflowGenerator {
29
32
  this.hasDocker = this.hosting.some((h) => h.name === 'Docker');
30
33
  this.primaryHosting = this.hosting.filter((h) => h.name !== 'Docker')[0] || null;
31
34
 
32
- // Monorepo mode: per-package workflows if configured or if > 1 package
35
+ // Monorepo mode: always keep the root matrix workflow for monorepos.
36
+ // Split layout may additionally emit one workflow per workspace.
33
37
  this.isMonorepo = this.monorepoPackages.length > 0;
34
- this.perPackageWorkflows = this.isMonorepo && (
38
+ this.perPackageWorkflows = this.workflowLayout === 'split' && this.isMonorepo && (
35
39
  (this.extraConfig.monorepo && this.extraConfig.monorepo.perPackage) ||
36
40
  this.monorepoPackages.length > 1
37
41
  );
42
+
43
+ // Initial runtime detection
44
+ this._detectRuntimeVersions();
45
+ }
46
+
47
+ _detectRuntimeVersions() {
48
+ const fs = require('fs');
49
+ const path = require('path');
50
+
51
+ // 1. Node.js
52
+ if (!this.primaryLang.nodeVersion) {
53
+ const nvmrcPath = path.join(this.projectPath, '.nvmrc');
54
+ if (fs.existsSync(nvmrcPath)) {
55
+ this.primaryLang.nodeVersion = fs.readFileSync(nvmrcPath, 'utf8').trim().replace('v', '');
56
+ } else {
57
+ try {
58
+ const pkg = JSON.parse(fs.readFileSync(path.join(this.projectPath, 'package.json'), 'utf8'));
59
+ if (pkg.engines && pkg.engines.node) {
60
+ const match = pkg.engines.node.match(/(\d+)/);
61
+ if (match) this.primaryLang.nodeVersion = match[1];
62
+ }
63
+ } catch (_) {}
64
+ }
65
+ this.primaryLang.nodeVersion = this.primaryLang.nodeVersion || '20';
66
+ }
67
+
68
+ // 2. Python
69
+ if (this.primaryLang.name === 'Python' && !this.primaryLang.pythonVersion) {
70
+ const pythonVersionPath = path.join(this.projectPath, '.python-version');
71
+ if (fs.existsSync(pythonVersionPath)) {
72
+ this.primaryLang.pythonVersion = fs.readFileSync(pythonVersionPath, 'utf8').trim();
73
+ } else {
74
+ this.primaryLang.pythonVersion = '3.11';
75
+ }
76
+ }
38
77
  }
39
78
 
40
79
  generate() {
41
80
  const workflows = [];
42
81
 
43
- if (this.perPackageWorkflows) {
44
- // ── Monorepo: one CI file per workspace ─────────────────────────────
45
- for (const pkg of this.monorepoPackages) {
46
- workflows.push({
47
- filename: `ci-${this._slugify(pkg.name)}.yml`,
48
- content: this._buildCIWorkflow(pkg),
49
- });
82
+ if (this.isMonorepo) {
83
+ if (this.perPackageWorkflows) {
84
+ // ── Monorepo split mode: one CI file per workspace ───────────────
85
+ for (const pkg of this.monorepoPackages) {
86
+ workflows.push({
87
+ filename: `ci-${this._slugify(pkg.name)}.yml`,
88
+ content: this._buildCIWorkflow(pkg),
89
+ });
90
+ }
50
91
  }
51
- // Root-level CI file (matrix over all packages)
92
+ // ── Monorepo root CI file (matrix over all packages) ──────────────
52
93
  workflows.push({
53
94
  filename: 'ci.yml',
54
95
  content: this._buildMonorepoRootCI(),
@@ -94,7 +135,7 @@ class WorkflowGenerator {
94
135
  const lang = this._langForPackage(pkg);
95
136
  const jobs = {};
96
137
 
97
- const branches = this.extraConfig.branches || ['main', 'master', 'develop'];
138
+ const branches = this._resolveBranches(['main', 'master', 'develop']);
98
139
 
99
140
  // ── lint job ──────────────────────────────────────────────────────────
100
141
  jobs.lint = {
@@ -104,8 +145,12 @@ class WorkflowGenerator {
104
145
  this._stepCheckout(),
105
146
  ...this._setupSteps(lang),
106
147
  this._stepInstallDeps(lang),
107
- this._stepLint(lang),
148
+ this._stepLint(lang, pkg),
108
149
  ].filter(Boolean),
150
+ env: {
151
+ CI: 'true',
152
+ ...this._getPublicEnv(),
153
+ },
109
154
  };
110
155
 
111
156
  // ── test job ──────────────────────────────────────────────────────────
@@ -117,16 +162,20 @@ class WorkflowGenerator {
117
162
  ...(testMatrix ? { strategy: testMatrix } : {}),
118
163
  steps: [
119
164
  this._stepCheckout(),
120
- ...this._setupSteps(lang),
165
+ ...this._setupSteps(lang, !!testMatrix),
121
166
  this._stepInstallDeps(lang),
122
- ...this._unitTestSteps(lang),
167
+ ...this._unitTestSteps(lang, pkg),
123
168
  this._stepUploadCoverage(),
124
169
  ].filter(Boolean),
170
+ env: {
171
+ CI: 'true',
172
+ ...this._getPublicEnv(),
173
+ },
125
174
  };
126
175
  }
127
176
 
128
177
  // ── build job ─────────────────────────────────────────────────────────
129
- const buildSteps = this._buildSteps(lang);
178
+ const buildSteps = this._buildSteps(lang, pkg);
130
179
  if (buildSteps.length > 0) {
131
180
  jobs.build = {
132
181
  name: '🏗️ Build',
@@ -137,18 +186,46 @@ class WorkflowGenerator {
137
186
  ...this._setupSteps(lang),
138
187
  this._stepInstallDeps(lang),
139
188
  ...buildSteps,
140
- this._stepUploadArtifact(),
189
+ this._stepUploadArtifact(lang),
141
190
  ].filter(Boolean),
191
+ env: {
192
+ NODE_ENV: 'production',
193
+ ...this._getPublicEnv(),
194
+ },
195
+ };
196
+ }
197
+
198
+ // ── lighthouse job ──────────────────────────────────────────────────
199
+ if (jobs.build && this.frameworks.some(f => ['Next.js', 'React', 'Vue', 'Svelte', 'Nuxt'].includes(f.name))) {
200
+ jobs.lighthouse = {
201
+ name: '⚡ Lighthouse Audit',
202
+ 'runs-on': 'ubuntu-latest',
203
+ needs: ['build'],
204
+ if: "github.event_name == 'pull_request'",
205
+ steps: [
206
+ this._stepCheckout(),
207
+ {
208
+ name: 'Run Lighthouse on build output',
209
+ uses: 'treosh/lighthouse-ci-action@v11',
210
+ with: {
211
+ uploadArtifacts: true,
212
+ temporaryPublicStorage: true,
213
+ configPath: './.lighthouserc.json',
214
+ },
215
+ },
216
+ ],
217
+ 'continue-on-error': true,
142
218
  };
143
219
  }
144
220
 
145
221
  // ── e2e job ───────────────────────────────────────────────────────────
146
222
  if (this.e2eTests.length > 0) {
147
223
  const e2eTest = this.e2eTests[0];
224
+ const e2eNeeds = jobs.build ? ['build'] : ['lint', ...(jobs.test ? ['test'] : [])];
148
225
  jobs.e2e = {
149
226
  name: '🎭 E2E Tests',
150
227
  'runs-on': 'ubuntu-latest',
151
- needs: ['build'],
228
+ needs: e2eNeeds,
152
229
  steps: [
153
230
  this._stepCheckout(),
154
231
  ...this._setupSteps(lang),
@@ -191,9 +268,13 @@ class WorkflowGenerator {
191
268
  };
192
269
 
193
270
  const envComment = this._envComment();
271
+ const pipelineStages = ['lint'];
272
+ if (jobs.test) pipelineStages.push('test');
273
+ if (jobs.build) pipelineStages.push('build');
274
+ if (jobs.e2e) pipelineStages.push('e2e');
194
275
  const header =
195
276
  `# Generated by cistack v${version} — https://github.com/cistack\n` +
196
- `# CI Pipeline: lint → test → build${this.e2eTests.length > 0 ? ' → e2e' : ''}\n` +
277
+ `# CI Pipeline: ${pipelineStages.join(' → ')}\n` +
197
278
  envComment +
198
279
  `\n`;
199
280
 
@@ -203,8 +284,18 @@ class WorkflowGenerator {
203
284
  // ── Monorepo root CI (matrix over all workspaces) ────────────────────────
204
285
  _buildMonorepoRootCI() {
205
286
  const lang = this.primaryLang;
206
- const branches = this.extraConfig.branches || ['main', 'master', 'develop'];
207
- const pkgPaths = this.monorepoPackages.map((p) => p.relativePath);
287
+ const branches = this._resolveBranches(['main', 'master', 'develop']);
288
+ const matrixEntries = this.monorepoPackages.map((pkg) => {
289
+ const pkgScripts = (pkg.packageJson && pkg.packageJson.scripts) || {};
290
+ return {
291
+ name: pkg.name,
292
+ package: pkg.relativePath,
293
+ lintScript: this._findScript(['lint', 'lint:ci', 'eslint'], pkg) || '',
294
+ testScript: (pkgScripts['test:ci'] && 'test:ci') || (pkgScripts.test && 'test') || '',
295
+ buildScript: this._findScript(['build', 'build:prod', 'compile'], pkg) || '',
296
+ };
297
+ });
298
+ const buildablePackages = matrixEntries.filter((pkg) => pkg.buildScript);
208
299
 
209
300
  const workflow = {
210
301
  name: 'CI — Monorepo',
@@ -222,50 +313,71 @@ class WorkflowGenerator {
222
313
  'runs-on': 'ubuntu-latest',
223
314
  strategy: {
224
315
  matrix: {
225
- package: pkgPaths,
316
+ include: matrixEntries,
226
317
  },
227
318
  'fail-fast': false,
228
319
  },
229
320
  steps: [
230
321
  this._stepCheckout(),
231
322
  ...this._setupSteps(lang),
232
- {
233
- name: 'Install dependencies',
234
- run: lang.packageManager === 'pnpm'
235
- ? 'pnpm --filter ${{ matrix.package }} install --frozen-lockfile'
236
- : lang.packageManager === 'yarn'
237
- ? 'yarn workspace ${{ matrix.package }} install'
238
- : 'npm ci --workspace=${{ matrix.package }}',
239
- },
323
+ this._stepInstallDeps(lang),
240
324
  {
241
325
  name: 'Lint',
242
- run: lang.packageManager === 'pnpm'
243
- ? 'pnpm --filter ${{ matrix.package }} run lint --if-present'
244
- : lang.packageManager === 'yarn'
245
- ? 'yarn workspace ${{ matrix.package }} run lint || true'
246
- : 'npm run --workspace=${{ matrix.package }} lint || true',
326
+ if: '${{ matrix.lintScript != \'\' }}',
327
+ run: this._workspaceRunCommand(lang, '${{ matrix.lintScript }}'),
247
328
  },
248
329
  {
249
330
  name: 'Test',
250
- run: lang.packageManager === 'pnpm'
251
- ? 'pnpm --filter ${{ matrix.package }} run test --if-present'
252
- : lang.packageManager === 'yarn'
253
- ? 'yarn workspace ${{ matrix.package }} run test || true'
254
- : 'npm run --workspace=${{ matrix.package }} test || true',
331
+ if: '${{ matrix.testScript != \'\' }}',
332
+ run: this._workspaceRunCommand(lang, '${{ matrix.testScript }}'),
255
333
  },
256
334
  {
257
335
  name: 'Build',
258
- run: lang.packageManager === 'pnpm'
259
- ? 'pnpm --filter ${{ matrix.package }} run build --if-present'
260
- : lang.packageManager === 'yarn'
261
- ? 'yarn workspace ${{ matrix.package }} run build || true'
262
- : 'npm run --workspace=${{ matrix.package }} build || true',
336
+ if: '${{ matrix.buildScript != \'\' }}',
337
+ run: this._workspaceRunCommand(lang, '${{ matrix.buildScript }}'),
263
338
  },
264
339
  ].filter(Boolean),
340
+ env: {
341
+ NODE_ENV: 'test',
342
+ CI: 'true',
343
+ ...this._getPublicEnv(),
344
+ },
265
345
  },
266
346
  },
267
347
  };
268
348
 
349
+ if (buildablePackages.length > 0 && this.frameworks.some(f => ['Next.js', 'React', 'Vue', 'Svelte', 'Nuxt'].includes(f.name))) {
350
+ workflow.jobs.lighthouse = {
351
+ name: '⚡ Lighthouse (Root)',
352
+ 'runs-on': 'ubuntu-latest',
353
+ strategy: {
354
+ matrix: {
355
+ include: buildablePackages,
356
+ },
357
+ },
358
+ steps: [
359
+ this._stepCheckout(),
360
+ ...this._setupSteps(lang),
361
+ {
362
+ ...this._stepInstallDeps(lang),
363
+ },
364
+ {
365
+ name: 'Build workspace',
366
+ run: this._workspaceRunCommand(lang, '${{ matrix.buildScript }}'),
367
+ },
368
+ {
369
+ name: 'Lighthouse',
370
+ uses: 'treosh/lighthouse-ci-action@v11',
371
+ with: {
372
+ uploadArtifacts: true,
373
+ temporaryPublicStorage: true,
374
+ },
375
+ },
376
+ ],
377
+ 'continue-on-error': true,
378
+ };
379
+ }
380
+
269
381
  return this._toYaml(
270
382
  workflow,
271
383
  `# Generated by cistack v${version} — https://github.com/cistack\n# Monorepo CI — matrix over all workspaces\n\n`
@@ -279,8 +391,11 @@ class WorkflowGenerator {
279
391
  _buildDeployWorkflow() {
280
392
  const h = this.primaryHosting;
281
393
  const lang = this.primaryLang;
282
- const branches = this.extraConfig.branches || ['main', 'master'];
394
+ const branches = this._resolveBranches(['main', 'master']);
395
+ const productionBranches = branches.filter((b) => b !== 'develop');
396
+ const deployBranches = productionBranches.length > 0 ? productionBranches : branches;
283
397
  const isGHPages = h.name === 'GitHub Pages';
398
+ const supportsPreview = ['Firebase', 'Vercel', 'Netlify'].includes(h.name);
284
399
 
285
400
  const preDeploySteps = [
286
401
  this._stepCheckout(),
@@ -288,9 +403,10 @@ class WorkflowGenerator {
288
403
  this._stepInstallDeps(lang),
289
404
  ].filter(Boolean);
290
405
 
291
- const deploySteps = this._hostingDeploySteps(h, lang, false); // production
292
- // GitHub Pages has no PR preview concept — skip preview job for it
293
- const previewSteps = isGHPages ? [] : this._hostingDeploySteps(h, lang, true);
406
+ const primaryDeployBranch = deployBranches[0] || this.defaultBranch || 'main';
407
+ const deploySteps = this._hostingDeploySteps(h, lang, false, primaryDeployBranch); // production
408
+ // Only generate PR preview steps for platforms that natively isolate them
409
+ const previewSteps = supportsPreview ? this._hostingDeploySteps(h, lang, true, primaryDeployBranch) : [];
294
410
 
295
411
  // GitHub Pages requires special permissions on the deploy job
296
412
  const ghPagesPermissions = isGHPages
@@ -315,7 +431,27 @@ class WorkflowGenerator {
315
431
  if: "github.event_name == 'pull_request'",
316
432
  'runs-on': 'ubuntu-latest',
317
433
  environment: 'preview',
318
- steps: [...preDeploySteps, ...previewSteps].filter(Boolean),
434
+ steps: [
435
+ ...preDeploySteps,
436
+ ...previewSteps,
437
+ {
438
+ name: 'Comment PR',
439
+ if: 'always()',
440
+ uses: 'actions/github-script@v7',
441
+ with: {
442
+ script: `
443
+ const deploymentUrl = process.env.DEPLOYMENT_URL;
444
+ if (!deploymentUrl) return;
445
+ github.rest.issues.createComment({
446
+ issue_number: context.issue.number,
447
+ owner: context.repo.owner,
448
+ repo: context.repo.repo,
449
+ body: '🚀 **Deployment Preview Ready!**\\n\\n[View Preview](' + deploymentUrl + ')'
450
+ });
451
+ `
452
+ }
453
+ }
454
+ ].filter(Boolean),
319
455
  };
320
456
  }
321
457
 
@@ -331,14 +467,18 @@ class WorkflowGenerator {
331
467
 
332
468
  const envComment = this._envComment();
333
469
 
334
- // GitHub Pages doesn't need pull_request trigger (no preview)
335
- const onTrigger = isGHPages
336
- ? { push: { branches: branches.filter((b) => b !== 'develop') }, workflow_dispatch: {} }
337
- : { push: { branches: branches.filter((b) => b !== 'develop') }, pull_request: { branches }, workflow_dispatch: {} };
470
+ // Only trigger on PR if the platform supports preview deployments
471
+ const onTrigger = supportsPreview
472
+ ? { push: { branches: deployBranches }, pull_request: { branches }, workflow_dispatch: {} }
473
+ : { push: { branches: deployBranches }, workflow_dispatch: {} };
338
474
 
339
475
  const workflow = {
340
476
  name: `Deploy to ${h.name}`,
341
477
  on: onTrigger,
478
+ concurrency: {
479
+ group: '${{ github.workflow }}-${{ github.ref }}',
480
+ 'cancel-in-progress': true,
481
+ },
342
482
  jobs,
343
483
  };
344
484
 
@@ -353,14 +493,15 @@ class WorkflowGenerator {
353
493
  // ══════════════════════════════════════════════════════════════════════════
354
494
 
355
495
  _buildDockerWorkflow() {
496
+ const branches = this._resolveBranches(['main', 'master']);
356
497
  const workflow = {
357
498
  name: 'Docker Build & Push',
358
499
  on: {
359
500
  push: {
360
- branches: ['main', 'master'],
501
+ branches,
361
502
  tags: ['v*.*.*'],
362
503
  },
363
- pull_request: { branches: ['main', 'master'] },
504
+ pull_request: { branches },
364
505
  },
365
506
  env: {
366
507
  REGISTRY: 'ghcr.io',
@@ -431,6 +572,7 @@ class WorkflowGenerator {
431
572
 
432
573
  _buildSecurityWorkflow() {
433
574
  const lang = this.primaryLang;
575
+ const branches = this._resolveBranches(['main', 'master']);
434
576
  const steps = [this._stepCheckout()];
435
577
 
436
578
  if (['JavaScript', 'TypeScript'].includes(lang.name)) {
@@ -450,7 +592,7 @@ class WorkflowGenerator {
450
592
 
451
593
  if (lang.name === 'Python') {
452
594
  steps.push(
453
- { name: 'Set up Python', uses: 'actions/setup-python@v5', with: { 'python-version': '3.x' } },
595
+ { name: 'Set up Python', uses: 'actions/setup-python@v5', with: { 'python-version': lang.pythonVersion || '3.11' } },
454
596
  { name: 'Install safety', run: 'pip install safety' },
455
597
  { name: 'Run safety check', run: 'safety check' },
456
598
  );
@@ -476,19 +618,19 @@ class WorkflowGenerator {
476
618
  const workflow = {
477
619
  name: 'Security Audit',
478
620
  on: {
479
- push: { branches: ['main', 'master'] },
480
- pull_request: { branches: ['main', 'master'] },
621
+ push: { branches },
622
+ pull_request: { branches },
481
623
  schedule: [{ cron: '0 6 * * 1' }],
482
624
  },
625
+ permissions: {
626
+ actions: 'read',
627
+ contents: 'read',
628
+ 'security-events': 'write',
629
+ },
483
630
  jobs: {
484
631
  security: {
485
632
  name: '🔒 Security Audit',
486
633
  'runs-on': 'ubuntu-latest',
487
- permissions: {
488
- actions: 'read',
489
- contents: 'read',
490
- 'security-events': 'write',
491
- },
492
634
  steps: steps.filter(Boolean),
493
635
  },
494
636
  },
@@ -509,12 +651,19 @@ class WorkflowGenerator {
509
651
  * Returns setup + cache steps for the given language.
510
652
  * v2.0.0: added explicit caching for pip, poetry, cargo, maven, gradle, bundler, go, composer.
511
653
  */
512
- _setupSteps(lang) {
654
+ _setupSteps(lang, useMatrix = false) {
513
655
  const steps = [];
514
656
  const cacheOverride = this.extraConfig.cache || {};
515
657
 
516
658
  // ── JavaScript / TypeScript ──────────────────────────────────────────
517
659
  if (['JavaScript', 'TypeScript'].includes(lang.name)) {
660
+ if (lang.packageManager === 'bun') {
661
+ steps.push({
662
+ name: 'Set up Bun',
663
+ uses: 'oven-sh/setup-bun@v2',
664
+ with: { 'bun-version': 'latest' },
665
+ });
666
+ }
518
667
  if (lang.packageManager === 'pnpm') {
519
668
  steps.push({ name: 'Install pnpm', uses: 'pnpm/action-setup@v3', with: { version: 'latest' } });
520
669
  }
@@ -522,9 +671,17 @@ class WorkflowGenerator {
522
671
  name: 'Set up Node.js',
523
672
  uses: 'actions/setup-node@v4',
524
673
  with: {
525
- 'node-version': lang.nodeVersion || '20',
674
+ 'node-version': useMatrix ? '${{ matrix.node-version }}' : (lang.nodeVersion || '20'),
526
675
  // Use native caching in setup-node
527
- cache: cacheOverride.npm !== false ? (lang.packageManager === 'yarn' ? 'yarn' : lang.packageManager === 'pnpm' ? 'pnpm' : 'npm') : undefined,
676
+ cache: cacheOverride.npm !== false
677
+ ? (lang.packageManager === 'yarn'
678
+ ? 'yarn'
679
+ : lang.packageManager === 'pnpm'
680
+ ? 'pnpm'
681
+ : lang.packageManager === 'npm'
682
+ ? 'npm'
683
+ : undefined)
684
+ : undefined,
528
685
  },
529
686
  });
530
687
  }
@@ -535,7 +692,7 @@ class WorkflowGenerator {
535
692
  name: 'Set up Python',
536
693
  uses: 'actions/setup-python@v5',
537
694
  with: {
538
- 'python-version': '3.x',
695
+ 'python-version': useMatrix ? '${{ matrix.python-version }}' : (lang.pythonVersion || '3.11'),
539
696
  // Native caching for pip/poetry
540
697
  cache: cacheOverride.pip !== false ? (lang.packageManager === 'poetry' ? 'poetry' : 'pip') : undefined
541
698
  },
@@ -623,9 +780,9 @@ class WorkflowGenerator {
623
780
 
624
781
  _stepInstallDeps(lang) {
625
782
  const pm = lang.packageManager;
626
- if (pm === 'npm') return { name: 'Install dependencies', run: 'npm ci' };
627
- if (pm === 'yarn') return { name: 'Install dependencies', run: 'yarn install --frozen-lockfile' };
628
- if (pm === 'pnpm') return { name: 'Install dependencies', run: 'pnpm install --frozen-lockfile' };
783
+ if (pm === 'npm') return { name: 'Install dependencies', run: this.lockFiles.has('package-lock.json') ? 'npm ci' : 'npm install' };
784
+ if (pm === 'yarn') return { name: 'Install dependencies', run: this.lockFiles.has('yarn.lock') ? 'yarn install --frozen-lockfile' : 'yarn install' };
785
+ if (pm === 'pnpm') return { name: 'Install dependencies', run: this.lockFiles.has('pnpm-lock.yaml') ? 'pnpm install --frozen-lockfile' : 'pnpm install' };
629
786
  if (pm === 'bun') return { name: 'Install dependencies', run: 'bun install' };
630
787
  if (pm === 'pip') return { name: 'Install dependencies', run: 'pip install -r requirements.txt' };
631
788
  if (pm === 'poetry') return { name: 'Install dependencies', run: 'pip install poetry && poetry install' };
@@ -639,46 +796,50 @@ class WorkflowGenerator {
639
796
  return { name: 'Install dependencies', run: 'npm ci' };
640
797
  }
641
798
 
642
- _stepLint(lang) {
643
- const pm = lang.packageManager || 'npm';
644
-
799
+ _stepLint(lang, pkg = null) {
645
800
  if (['JavaScript', 'TypeScript'].includes(lang.name)) {
646
- const lintScript = this._findScript(['lint', 'lint:ci', 'eslint']);
647
- const typeCheck = this._findScript(['type-check', 'typecheck', 'tsc']);
648
- const format = this._findScript(['format:check', 'prettier:check', 'fmt:check']);
649
- const runPfx = pm === 'yarn' ? 'yarn' : pm === 'pnpm' ? 'pnpm' : 'npm run';
801
+ const lintScript = this._findScript(['lint', 'lint:ci', 'eslint'], pkg);
802
+ const typeCheck = this._findScript(['type-check', 'typecheck', 'tsc'], pkg);
803
+ const format = this._findScript(['format:check', 'prettier:check', 'fmt:check'], pkg);
650
804
 
651
805
  const cmds = [];
652
- if (lintScript) cmds.push(`${runPfx} ${lintScript}`);
653
- if (typeCheck) cmds.push(`${runPfx} ${typeCheck}`);
654
- if (format) cmds.push(`${runPfx} ${format}`);
655
- if (cmds.length === 0) cmds.push('npx eslint . --ext .js,.jsx,.ts,.tsx --max-warnings 0');
806
+ if (lintScript) cmds.push(this._scriptCommand(lang, lintScript, pkg));
807
+ if (typeCheck) cmds.push(this._scriptCommand(lang, typeCheck, pkg));
808
+ if (format) cmds.push(this._scriptCommand(lang, format, pkg));
809
+ if (cmds.length === 0) {
810
+ const lintTarget = pkg ? pkg.relativePath : '.';
811
+ cmds.push(`npx eslint ${lintTarget} --ext .js,.jsx,.ts,.tsx --max-warnings 0`);
812
+ }
656
813
 
657
814
  return { name: 'Lint', run: cmds.join('\n') };
658
815
  }
659
816
 
660
- if (lang.name === 'Python') return { name: 'Lint', run: 'pip install flake8 && flake8 .' };
817
+ if (lang.name === 'Python') return { name: 'Lint', run: 'pip install flake8 black && flake8 . && black --check .' };
661
818
  if (lang.name === 'Go') return { name: 'Lint', run: 'gofmt -d . && go vet ./...' };
662
819
  if (lang.name === 'Rust') return { name: 'Lint', run: 'cargo clippy -- -D warnings && cargo fmt --check' };
663
820
  if (lang.name === 'Ruby') return { name: 'Lint', run: 'gem install rubocop && rubocop' };
664
- if (lang.name === 'PHP') return { name: 'Lint', run: 'vendor/bin/phpcs' };
821
+ if (lang.name === 'PHP') return { name: 'Lint', run: 'vendor/bin/phpcs && vendor/bin/phpstan analyze' };
665
822
 
666
823
  return null;
667
824
  }
668
825
 
669
- _unitTestSteps(lang) {
826
+ _unitTestSteps(lang, pkg = null) {
827
+ if (pkg && ['JavaScript', 'TypeScript'].includes(lang.name)) {
828
+ const pkgTestScript = this._findScript(['test:ci', 'test'], pkg);
829
+ if (pkgTestScript) {
830
+ return [{ name: 'Run tests', run: this._scriptCommand(lang, pkgTestScript, pkg) }];
831
+ }
832
+ }
670
833
  return this.unitTests.map((t) => ({ name: `Run ${t.name}`, run: t.command }));
671
834
  }
672
835
 
673
- _buildSteps(lang) {
674
- const pm = lang.packageManager || 'npm';
675
- const buildScript = this._findScript(['build', 'build:prod']);
836
+ _buildSteps(lang, pkg = null) {
837
+ const buildScript = this._findScript(['build', 'build:prod', 'compile'], pkg);
676
838
  if (!buildScript && !['Go', 'Rust', 'Java', 'Kotlin'].includes(lang.name)) return [];
677
839
 
678
840
  const steps = [];
679
841
  if (['JavaScript', 'TypeScript'].includes(lang.name) && buildScript) {
680
- const runPfx = pm === 'yarn' ? 'yarn' : pm === 'pnpm' ? 'pnpm' : 'npm';
681
- steps.push({ name: 'Build', run: `${runPfx} run ${buildScript}`, env: { NODE_ENV: 'production' } });
842
+ steps.push({ name: 'Build', run: this._scriptCommand(lang, buildScript, pkg), env: { NODE_ENV: 'production' } });
682
843
  }
683
844
  if (lang.name === 'Go') steps.push({ name: 'Build', run: 'go build -v ./...' });
684
845
  if (lang.name === 'Rust') steps.push({ name: 'Build', run: 'cargo build --release' });
@@ -688,8 +849,21 @@ class WorkflowGenerator {
688
849
  return steps;
689
850
  }
690
851
 
691
- _stepUploadArtifact() {
692
- const buildDir = (this.frameworks[0] && this.frameworks[0].buildDir) || 'dist';
852
+ _stepUploadArtifact(lang) {
853
+ let buildDir = null;
854
+
855
+ if (['JavaScript', 'TypeScript'].includes(lang.name)) {
856
+ buildDir = (this.frameworks[0] && this.frameworks[0].buildDir) || null;
857
+ } else if (lang.name === 'Rust') {
858
+ buildDir = 'target/release';
859
+ } else if (lang.name === 'Java') {
860
+ buildDir = 'target';
861
+ } else if (lang.name === 'Kotlin') {
862
+ buildDir = 'build/libs';
863
+ }
864
+
865
+ if (!buildDir) return null;
866
+
693
867
  return {
694
868
  name: 'Upload build artifact',
695
869
  uses: 'actions/upload-artifact@v4',
@@ -719,15 +893,48 @@ class WorkflowGenerator {
719
893
  return null;
720
894
  }
721
895
 
896
+ _resolveBranches(fallback) {
897
+ if (Array.isArray(this.extraConfig.branches) && this.extraConfig.branches.length > 0) {
898
+ return [...new Set(this.extraConfig.branches)];
899
+ }
900
+ if (this.defaultBranch) {
901
+ return [this.defaultBranch];
902
+ }
903
+ return fallback;
904
+ }
905
+
906
+ _workspaceRunCommand(lang, scriptName) {
907
+ const pm = lang.packageManager || 'npm';
908
+ if (pm === 'pnpm') return `pnpm --filter \${{ matrix.package }} run ${scriptName}`;
909
+ if (pm === 'yarn') return `yarn workspace \${{ matrix.name }} run ${scriptName}`;
910
+ if (pm === 'bun') return `bun run --filter './\${{ matrix.package }}' ${scriptName}`;
911
+ return `npm run ${scriptName} --workspace=\${{ matrix.name }}`;
912
+ }
913
+
914
+ _scriptCommand(lang, scriptName, pkg = null) {
915
+ const pm = lang.packageManager || 'npm';
916
+
917
+ if (pkg) {
918
+ if (pm === 'pnpm') return `pnpm --filter ${pkg.relativePath} run ${scriptName}`;
919
+ if (pm === 'yarn') return `yarn workspace ${pkg.name} run ${scriptName}`;
920
+ if (pm === 'bun') return `bun run --filter './${pkg.relativePath}' ${scriptName}`;
921
+ return `npm run ${scriptName} --workspace=${pkg.name}`;
922
+ }
923
+
924
+ if (pm === 'yarn') return `yarn run ${scriptName}`;
925
+ if (pm === 'pnpm') return `pnpm run ${scriptName}`;
926
+ if (pm === 'bun') return `bun run ${scriptName}`;
927
+ return `npm run ${scriptName}`;
928
+ }
929
+
722
930
  // ══════════════════════════════════════════════════════════════════════════
723
931
  // Hosting-specific deploy steps
724
932
  // ══════════════════════════════════════════════════════════════════════════
725
933
 
726
- _hostingDeploySteps(h, lang, isPreview = false) {
934
+ _hostingDeploySteps(h, lang, isPreview = false, productionBranch = null) {
727
935
  const steps = [];
728
936
  const buildScript = this._findScript(['build', 'build:prod']);
729
- const pm = lang.packageManager || 'npm';
730
- const runCmd = (s) => `${pm === 'yarn' ? 'yarn' : pm === 'pnpm' ? 'pnpm' : 'npm'} run ${s}`;
937
+ const runCmd = (s) => this._scriptCommand(lang, s);
731
938
 
732
939
  switch (h.name) {
733
940
  case 'Firebase': {
@@ -767,7 +974,8 @@ class WorkflowGenerator {
767
974
  },
768
975
  {
769
976
  name: 'Deploy to Vercel',
770
- run: `vercel deploy --prebuilt${prodFlag ? ' ' + prodFlag : ''} --token=\${{ secrets.VERCEL_TOKEN }}`,
977
+ id: 'deploy',
978
+ run: `vercel deploy --prebuilt${prodFlag ? ' ' + prodFlag : ''} --token=\${{ secrets.VERCEL_TOKEN }} > deployment_url.txt && echo "DEPLOYMENT_URL=$(cat deployment_url.txt)" >> $GITHUB_ENV`,
771
979
  env: vercelEnv,
772
980
  },
773
981
  );
@@ -780,10 +988,11 @@ class WorkflowGenerator {
780
988
  }
781
989
  steps.push({
782
990
  name: isPreview ? 'Deploy Preview' : 'Deploy to Netlify',
991
+ id: 'netlify_deploy',
783
992
  uses: 'nwtgck/actions-netlify@v3.0',
784
993
  with: {
785
- 'publish-dir': h.publishDir || 'dist',
786
- 'production-branch': 'main',
994
+ 'publish-dir': h.publishDir || (this.frameworks[0] && this.frameworks[0].buildDir) || 'dist',
995
+ 'production-branch': productionBranch || this.defaultBranch || 'main',
787
996
  'github-token': '${{ secrets.GITHUB_TOKEN }}',
788
997
  'deploy-message': isPreview ? 'Preview Deploy – ${{ github.event.number }}' : 'Production Deploy – ${{ github.sha }}',
789
998
  'enable-pull-request-comment': true,
@@ -796,6 +1005,12 @@ class WorkflowGenerator {
796
1005
  NETLIFY_SITE_ID: '${{ secrets.NETLIFY_SITE_ID }}',
797
1006
  },
798
1007
  });
1008
+ if (isPreview) {
1009
+ steps.push({
1010
+ name: 'Set Netlify URL',
1011
+ run: 'echo "DEPLOYMENT_URL=${{ steps.netlify_deploy.outputs.deploy-url }}" >> $GITHUB_ENV'
1012
+ });
1013
+ }
799
1014
  break;
800
1015
  }
801
1016
 
@@ -930,11 +1145,12 @@ class WorkflowGenerator {
930
1145
  return lang;
931
1146
  }
932
1147
 
933
- _findScript(names) {
1148
+ _findScript(names, pkg = null) {
934
1149
  const fs = require('fs');
935
1150
  const path = require('path');
936
1151
  try {
937
- const raw = JSON.parse(fs.readFileSync(path.join(this.projectPath, 'package.json'), 'utf8'));
1152
+ const pkgPath = pkg ? path.join(this.projectPath, pkg.relativePath, 'package.json') : path.join(this.projectPath, 'package.json');
1153
+ const raw = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
938
1154
  const scripts = raw.scripts || {};
939
1155
  for (const n of names) {
940
1156
  if (scripts[n]) return n;
@@ -973,6 +1189,21 @@ class WorkflowGenerator {
973
1189
  });
974
1190
  return header + raw;
975
1191
  }
1192
+
1193
+ _getPublicEnv() {
1194
+ const env = {};
1195
+ if (this.envVars && this.envVars.public) {
1196
+ for (const p of this.envVars.public) {
1197
+ const parts = p.split('=');
1198
+ if (parts.length >= 2) {
1199
+ env[parts[0]] = parts.slice(1).join('=');
1200
+ } else {
1201
+ env[p] = `\${{ vars.${p} || secrets.${p} }}`;
1202
+ }
1203
+ }
1204
+ }
1205
+ return env;
1206
+ }
976
1207
  }
977
1208
 
978
1209
  module.exports = WorkflowGenerator;