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