cistack 3.1.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,7 +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;
393
+ const isGHPages = h.name === 'GitHub Pages';
394
+ const supportsPreview = ['Firebase', 'Vercel', 'Netlify'].includes(h.name);
283
395
 
284
396
  const preDeploySteps = [
285
397
  this._stepCheckout(),
@@ -287,15 +399,23 @@ class WorkflowGenerator {
287
399
  this._stepInstallDeps(lang),
288
400
  ].filter(Boolean);
289
401
 
290
- const deploySteps = this._hostingDeploySteps(h, lang, false); // production
291
- const previewSteps = this._hostingDeploySteps(h, lang, true); // preview
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) : [];
406
+
407
+ // GitHub Pages requires special permissions on the deploy job
408
+ const ghPagesPermissions = isGHPages
409
+ ? { pages: 'write', 'id-token': 'write', contents: 'read' }
410
+ : undefined;
292
411
 
293
412
  const jobs = {
294
413
  deploy: {
295
414
  name: `🚀 Deploy → ${h.name} (Production)`,
296
415
  if: "github.event_name == 'push' || github.event_name == 'workflow_dispatch'",
297
416
  'runs-on': 'ubuntu-latest',
298
- environment: 'production',
417
+ environment: isGHPages ? 'github-pages' : 'production',
418
+ ...(ghPagesPermissions ? { permissions: ghPagesPermissions } : {}),
299
419
  steps: [...preDeploySteps, ...deploySteps].filter(Boolean),
300
420
  },
301
421
  };
@@ -307,7 +427,27 @@ class WorkflowGenerator {
307
427
  if: "github.event_name == 'pull_request'",
308
428
  'runs-on': 'ubuntu-latest',
309
429
  environment: 'preview',
310
- 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),
311
451
  };
312
452
  }
313
453
 
@@ -323,12 +463,17 @@ class WorkflowGenerator {
323
463
 
324
464
  const envComment = this._envComment();
325
465
 
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: {} };
470
+
326
471
  const workflow = {
327
472
  name: `Deploy to ${h.name}`,
328
- on: {
329
- push: { branches: branches.filter((b) => b !== 'develop') },
330
- pull_request: { branches },
331
- workflow_dispatch: {},
473
+ on: onTrigger,
474
+ concurrency: {
475
+ group: '${{ github.workflow }}-${{ github.ref }}',
476
+ 'cancel-in-progress': true,
332
477
  },
333
478
  jobs,
334
479
  };
@@ -344,14 +489,15 @@ class WorkflowGenerator {
344
489
  // ══════════════════════════════════════════════════════════════════════════
345
490
 
346
491
  _buildDockerWorkflow() {
492
+ const branches = this._resolveBranches(['main', 'master']);
347
493
  const workflow = {
348
494
  name: 'Docker Build & Push',
349
495
  on: {
350
496
  push: {
351
- branches: ['main', 'master'],
497
+ branches,
352
498
  tags: ['v*.*.*'],
353
499
  },
354
- pull_request: { branches: ['main', 'master'] },
500
+ pull_request: { branches },
355
501
  },
356
502
  env: {
357
503
  REGISTRY: 'ghcr.io',
@@ -422,6 +568,7 @@ class WorkflowGenerator {
422
568
 
423
569
  _buildSecurityWorkflow() {
424
570
  const lang = this.primaryLang;
571
+ const branches = this._resolveBranches(['main', 'master']);
425
572
  const steps = [this._stepCheckout()];
426
573
 
427
574
  if (['JavaScript', 'TypeScript'].includes(lang.name)) {
@@ -441,7 +588,7 @@ class WorkflowGenerator {
441
588
 
442
589
  if (lang.name === 'Python') {
443
590
  steps.push(
444
- { 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' } },
445
592
  { name: 'Install safety', run: 'pip install safety' },
446
593
  { name: 'Run safety check', run: 'safety check' },
447
594
  );
@@ -467,19 +614,19 @@ class WorkflowGenerator {
467
614
  const workflow = {
468
615
  name: 'Security Audit',
469
616
  on: {
470
- push: { branches: ['main', 'master'] },
471
- pull_request: { branches: ['main', 'master'] },
617
+ push: { branches },
618
+ pull_request: { branches },
472
619
  schedule: [{ cron: '0 6 * * 1' }],
473
620
  },
621
+ permissions: {
622
+ actions: 'read',
623
+ contents: 'read',
624
+ 'security-events': 'write',
625
+ },
474
626
  jobs: {
475
627
  security: {
476
628
  name: '🔒 Security Audit',
477
629
  'runs-on': 'ubuntu-latest',
478
- permissions: {
479
- actions: 'read',
480
- contents: 'read',
481
- 'security-events': 'write',
482
- },
483
630
  steps: steps.filter(Boolean),
484
631
  },
485
632
  },
@@ -500,12 +647,19 @@ class WorkflowGenerator {
500
647
  * Returns setup + cache steps for the given language.
501
648
  * v2.0.0: added explicit caching for pip, poetry, cargo, maven, gradle, bundler, go, composer.
502
649
  */
503
- _setupSteps(lang) {
650
+ _setupSteps(lang, useMatrix = false) {
504
651
  const steps = [];
505
652
  const cacheOverride = this.extraConfig.cache || {};
506
653
 
507
654
  // ── JavaScript / TypeScript ──────────────────────────────────────────
508
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
+ }
509
663
  if (lang.packageManager === 'pnpm') {
510
664
  steps.push({ name: 'Install pnpm', uses: 'pnpm/action-setup@v3', with: { version: 'latest' } });
511
665
  }
@@ -513,9 +667,17 @@ class WorkflowGenerator {
513
667
  name: 'Set up Node.js',
514
668
  uses: 'actions/setup-node@v4',
515
669
  with: {
516
- 'node-version': lang.nodeVersion || '20',
670
+ 'node-version': useMatrix ? '${{ matrix.node-version }}' : (lang.nodeVersion || '20'),
517
671
  // Use native caching in setup-node
518
- 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,
519
681
  },
520
682
  });
521
683
  }
@@ -526,7 +688,7 @@ class WorkflowGenerator {
526
688
  name: 'Set up Python',
527
689
  uses: 'actions/setup-python@v5',
528
690
  with: {
529
- 'python-version': '3.x',
691
+ 'python-version': useMatrix ? '${{ matrix.python-version }}' : (lang.pythonVersion || '3.11'),
530
692
  // Native caching for pip/poetry
531
693
  cache: cacheOverride.pip !== false ? (lang.packageManager === 'poetry' ? 'poetry' : 'pip') : undefined
532
694
  },
@@ -614,9 +776,9 @@ class WorkflowGenerator {
614
776
 
615
777
  _stepInstallDeps(lang) {
616
778
  const pm = lang.packageManager;
617
- if (pm === 'npm') return { name: 'Install dependencies', run: 'npm ci' };
618
- if (pm === 'yarn') return { name: 'Install dependencies', run: 'yarn install --frozen-lockfile' };
619
- 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' };
620
782
  if (pm === 'bun') return { name: 'Install dependencies', run: 'bun install' };
621
783
  if (pm === 'pip') return { name: 'Install dependencies', run: 'pip install -r requirements.txt' };
622
784
  if (pm === 'poetry') return { name: 'Install dependencies', run: 'pip install poetry && poetry install' };
@@ -630,46 +792,50 @@ class WorkflowGenerator {
630
792
  return { name: 'Install dependencies', run: 'npm ci' };
631
793
  }
632
794
 
633
- _stepLint(lang) {
634
- const pm = lang.packageManager || 'npm';
635
-
795
+ _stepLint(lang, pkg = null) {
636
796
  if (['JavaScript', 'TypeScript'].includes(lang.name)) {
637
- const lintScript = this._findScript(['lint', 'lint:ci', 'eslint']);
638
- const typeCheck = this._findScript(['type-check', 'typecheck', 'tsc']);
639
- const format = this._findScript(['format:check', 'prettier:check', 'fmt:check']);
640
- 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);
641
800
 
642
801
  const cmds = [];
643
- if (lintScript) cmds.push(`${runPfx} ${lintScript}`);
644
- if (typeCheck) cmds.push(`${runPfx} ${typeCheck}`);
645
- if (format) cmds.push(`${runPfx} ${format}`);
646
- 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
+ }
647
809
 
648
810
  return { name: 'Lint', run: cmds.join('\n') };
649
811
  }
650
812
 
651
- 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 .' };
652
814
  if (lang.name === 'Go') return { name: 'Lint', run: 'gofmt -d . && go vet ./...' };
653
815
  if (lang.name === 'Rust') return { name: 'Lint', run: 'cargo clippy -- -D warnings && cargo fmt --check' };
654
816
  if (lang.name === 'Ruby') return { name: 'Lint', run: 'gem install rubocop && rubocop' };
655
- 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' };
656
818
 
657
819
  return null;
658
820
  }
659
821
 
660
- _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
+ }
661
829
  return this.unitTests.map((t) => ({ name: `Run ${t.name}`, run: t.command }));
662
830
  }
663
831
 
664
- _buildSteps(lang) {
665
- const pm = lang.packageManager || 'npm';
666
- const buildScript = this._findScript(['build', 'build:prod']);
832
+ _buildSteps(lang, pkg = null) {
833
+ const buildScript = this._findScript(['build', 'build:prod', 'compile'], pkg);
667
834
  if (!buildScript && !['Go', 'Rust', 'Java', 'Kotlin'].includes(lang.name)) return [];
668
835
 
669
836
  const steps = [];
670
837
  if (['JavaScript', 'TypeScript'].includes(lang.name) && buildScript) {
671
- const runPfx = pm === 'yarn' ? 'yarn' : pm === 'pnpm' ? 'pnpm' : 'npm';
672
- 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' } });
673
839
  }
674
840
  if (lang.name === 'Go') steps.push({ name: 'Build', run: 'go build -v ./...' });
675
841
  if (lang.name === 'Rust') steps.push({ name: 'Build', run: 'cargo build --release' });
@@ -679,8 +845,21 @@ class WorkflowGenerator {
679
845
  return steps;
680
846
  }
681
847
 
682
- _stepUploadArtifact() {
683
- 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
+
684
863
  return {
685
864
  name: 'Upload build artifact',
686
865
  uses: 'actions/upload-artifact@v4',
@@ -710,15 +889,48 @@ class WorkflowGenerator {
710
889
  return null;
711
890
  }
712
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
+
713
926
  // ══════════════════════════════════════════════════════════════════════════
714
927
  // Hosting-specific deploy steps
715
928
  // ══════════════════════════════════════════════════════════════════════════
716
929
 
717
- _hostingDeploySteps(h, lang, isPreview = false) {
930
+ _hostingDeploySteps(h, lang, isPreview = false, productionBranch = null) {
718
931
  const steps = [];
719
932
  const buildScript = this._findScript(['build', 'build:prod']);
720
- const pm = lang.packageManager || 'npm';
721
- const runCmd = (s) => `${pm === 'yarn' ? 'yarn' : pm === 'pnpm' ? 'pnpm' : 'npm'} run ${s}`;
933
+ const runCmd = (s) => this._scriptCommand(lang, s);
722
934
 
723
935
  switch (h.name) {
724
936
  case 'Firebase': {
@@ -739,11 +951,29 @@ class WorkflowGenerator {
739
951
 
740
952
  case 'Vercel': {
741
953
  const prodFlag = isPreview ? '' : '--prod';
954
+ const vercelEnv = {
955
+ VERCEL_TOKEN: '${{ secrets.VERCEL_TOKEN }}',
956
+ VERCEL_ORG_ID: '${{ secrets.VERCEL_ORG_ID }}',
957
+ VERCEL_PROJECT_ID: '${{ secrets.VERCEL_PROJECT_ID }}',
958
+ };
742
959
  steps.push(
743
960
  { name: 'Install Vercel CLI', run: 'npm install -g vercel' },
744
- { name: 'Pull Vercel environment', run: `vercel pull --yes --environment=${isPreview ? 'preview' : 'production'} --token=\${{ secrets.VERCEL_TOKEN }}` },
745
- { name: 'Build project', run: `vercel build${prodFlag ? ' ' + prodFlag : ''} --token=\${{ secrets.VERCEL_TOKEN }}` },
746
- { name: 'Deploy to Vercel', run: `vercel deploy --prebuilt${prodFlag ? ' ' + prodFlag : ''} --token=\${{ secrets.VERCEL_TOKEN }}` },
961
+ {
962
+ name: 'Pull Vercel environment',
963
+ run: `vercel pull --yes --environment=${isPreview ? 'preview' : 'production'} --token=\${{ secrets.VERCEL_TOKEN }}`,
964
+ env: vercelEnv,
965
+ },
966
+ {
967
+ name: 'Build project',
968
+ run: `vercel build${prodFlag ? ' ' + prodFlag : ''} --token=\${{ secrets.VERCEL_TOKEN }}`,
969
+ env: vercelEnv,
970
+ },
971
+ {
972
+ name: 'Deploy to Vercel',
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`,
975
+ env: vercelEnv,
976
+ },
747
977
  );
748
978
  break;
749
979
  }
@@ -754,10 +984,11 @@ class WorkflowGenerator {
754
984
  }
755
985
  steps.push({
756
986
  name: isPreview ? 'Deploy Preview' : 'Deploy to Netlify',
987
+ id: 'netlify_deploy',
757
988
  uses: 'nwtgck/actions-netlify@v3.0',
758
989
  with: {
759
- 'publish-dir': h.publishDir || 'dist',
760
- 'production-branch': 'main',
990
+ 'publish-dir': h.publishDir || (this.frameworks[0] && this.frameworks[0].buildDir) || 'dist',
991
+ 'production-branch': productionBranch || this.defaultBranch || 'main',
761
992
  'github-token': '${{ secrets.GITHUB_TOKEN }}',
762
993
  'deploy-message': isPreview ? 'Preview Deploy – ${{ github.event.number }}' : 'Production Deploy – ${{ github.sha }}',
763
994
  'enable-pull-request-comment': true,
@@ -770,6 +1001,12 @@ class WorkflowGenerator {
770
1001
  NETLIFY_SITE_ID: '${{ secrets.NETLIFY_SITE_ID }}',
771
1002
  },
772
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
+ }
773
1010
  break;
774
1011
  }
775
1012
 
@@ -832,7 +1069,10 @@ class WorkflowGenerator {
832
1069
  }
833
1070
 
834
1071
  case 'Render': {
835
- steps.push({ name: 'Trigger Render deploy', run: 'curl -X POST ${{ secrets.RENDER_DEPLOY_HOOK_URL }}' });
1072
+ // Render doesn't support PR preview deploys via deploy hook
1073
+ if (!isPreview) {
1074
+ steps.push({ name: 'Trigger Render deploy', run: 'curl -X POST "${{ secrets.RENDER_DEPLOY_HOOK_URL }}"' });
1075
+ }
836
1076
  break;
837
1077
  }
838
1078
 
@@ -901,11 +1141,12 @@ class WorkflowGenerator {
901
1141
  return lang;
902
1142
  }
903
1143
 
904
- _findScript(names) {
1144
+ _findScript(names, pkg = null) {
905
1145
  const fs = require('fs');
906
1146
  const path = require('path');
907
1147
  try {
908
- 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'));
909
1150
  const scripts = raw.scripts || {};
910
1151
  for (const n of names) {
911
1152
  if (scripts[n]) return n;
@@ -944,6 +1185,21 @@ class WorkflowGenerator {
944
1185
  });
945
1186
  return header + raw;
946
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
+ }
947
1203
  }
948
1204
 
949
1205
  module.exports = WorkflowGenerator;