@akash-chowdhury-24/deployhub 2.0.29 → 2.0.31

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/README.md CHANGED
@@ -651,8 +651,8 @@ All JS frontends share the same install/build flow: `npm ci` → `npm run build`
651
651
 
652
652
  - **Detect:** `composer.json` with `laravel/framework` or `symfony/framework-bundle`.
653
653
  - **Install:** Composer (on CI and server).
654
- - **CI runtime:** Generated workflows install PHP via `shivammathur/setup-php@v2` with Composer. Default version is **8.4** (meets current Laravel platform requirements). Override with `"phpVersion": "8.3"` at the config root or under `"backend"` in `deployhub.config.json`, then run `deployhub sync-workflows`.
655
- - **Deploy:** SSH with PHP-FPM or `php artisan` for Laravel.
654
+ - **CI / Docker runtime:** Generated workflows install PHP via `shivammathur/setup-php@v2`, and PHP Dockerfiles use `php:{version}-cli-alpine` (verified tag pattern, e.g. `php:8.4-cli-alpine`). Both share `resolvePhpVersion()` default **8.4**. Override with `"phpVersion": "8.3"` at the config root or under `"backend"` in `deployhub.config.json`, then regenerate the Dockerfile (delete the existing one so DeployHub can rewrite it) and run `deployhub sync-workflows` for CI.
655
+ - **Deploy (SSH):** Assumes host php-fpm + nginx are already installed and configured for the deploy path. Restarts `php{version}-fpm` when present (from `resolvePhpVersion()`, default **8.4** → `php8.4-fpm`), or generic `php-fpm` on Amazon Linux/RHEL. Runs remote `composer install --no-dev` (and Laravel migrate/config:cache). Config `startCommand` (e.g. `php artisan serve`) is **not** used on SSH — FPM+nginx only.
656
656
 
657
657
  > ⚠️ **PHP-FPM deployments restart the FPM service for the ENTIRE host on every deploy.** If you run multiple DeployHub-managed environments on the same server, deploying ANY of them will briefly interrupt in-flight requests for ALL of them. For production use with multiple environments, either use separate hosts per environment, or set up per-environment PHP-FPM pools manually (not yet automated by DeployHub).
658
658
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akash-chowdhury-24/deployhub",
3
- "version": "2.0.29",
3
+ "version": "2.0.31",
4
4
  "description": "Zero-configuration deployment and artifact manager",
5
5
  "type": "module",
6
6
  "main": "./src/cli/index.js",
@@ -19,7 +19,6 @@ import {
19
19
  getEnvSettings,
20
20
  resolveDefaultEnvironmentName,
21
21
  } from '../core/config.js';
22
- import { detectDjangoWsgiPackageDir } from '../utils/python-app-target.js';
23
22
 
24
23
  /**
25
24
  * @param {string} cwd
@@ -183,66 +182,117 @@ async function stageFrontendArtifact(cwd, stagingDir, config) {
183
182
  * @param {string} stagingDir
184
183
  * @param {import('../core/config.js').DeployHubConfig} config
185
184
  */
185
+ /** Dirs/files that must never be packed into a backend artifact. */
186
+ const BACKEND_STAGE_EXCLUDE = new Set([
187
+ 'node_modules',
188
+ '.git',
189
+ '.github',
190
+ 'artifact',
191
+ '.deployhub',
192
+ '.deployhub-storage',
193
+ '.deployhub-restore',
194
+ '.deployhub-doctor-test',
195
+ 'coverage',
196
+ '.venv',
197
+ 'venv',
198
+ '__pycache__',
199
+ 'vendor',
200
+ '.idea',
201
+ '.vscode',
202
+ '.nyc_output',
203
+ ]);
204
+
205
+ /**
206
+ * Copy the backend project tree into staging, skipping install caches and VCS.
207
+ * Allowlists previously omitted root entrypoints (server.js, main.go, index.php)
208
+ * which made SSH `npm start` / binary start fail even though CI built fine.
209
+ *
210
+ * @param {string} cwd
211
+ * @param {string} stagingDir
212
+ * @param {Set<string>} extraExclude
213
+ */
214
+ async function copyBackendSourceTree(cwd, stagingDir, extraExclude = new Set()) {
215
+ const exclude = new Set([...BACKEND_STAGE_EXCLUDE, ...extraExclude]);
216
+ let entries = [];
217
+ try {
218
+ entries = await fs.readdir(cwd, { withFileTypes: true });
219
+ } catch {
220
+ return;
221
+ }
222
+
223
+ for (const ent of entries) {
224
+ if (exclude.has(ent.name)) continue;
225
+ if (ent.name.startsWith('.env') && ent.name !== '.env.example') continue;
226
+ const src = path.join(cwd, ent.name);
227
+ const dest = path.join(stagingDir, ent.name);
228
+ if (ent.isDirectory()) {
229
+ await fs.copy(src, dest, {
230
+ filter: (p) => {
231
+ const base = path.basename(p);
232
+ if (exclude.has(base)) return false;
233
+ if (base === '__pycache__' || base === 'node_modules' || base === '.git') {
234
+ return false;
235
+ }
236
+ return true;
237
+ },
238
+ });
239
+ } else {
240
+ await fs.copy(src, dest);
241
+ }
242
+ }
243
+ }
244
+
245
+ /**
246
+ * @param {string} stagingDir
247
+ */
248
+ async function removeStagingDir(stagingDir) {
249
+ let lastErr;
250
+ for (let attempt = 0; attempt < 6; attempt++) {
251
+ try {
252
+ await fs.remove(stagingDir);
253
+ return;
254
+ } catch (err) {
255
+ lastErr = err;
256
+ await new Promise((r) => setTimeout(r, 50 * (attempt + 1)));
257
+ }
258
+ }
259
+ const message = lastErr instanceof Error ? lastErr.message : String(lastErr);
260
+ createLogger('artifact').warn(
261
+ `Could not remove staging dir after zip (artifact is still valid): ${message}`
262
+ );
263
+ }
264
+
186
265
  async function stageBackendArtifact(cwd, stagingDir, config) {
187
266
  const settings = resolveBuildSettings(config);
188
267
  const framework = settings.framework;
189
268
 
190
- await copyDirectoryIfExists(cwd, stagingDir, 'src');
191
-
192
- for (const file of ['Dockerfile', 'docker-compose.yml', '.env.example']) {
193
- await copyIfExists(cwd, stagingDir, file);
269
+ /** @type {Set<string>} */
270
+ const extraExclude = new Set();
271
+ // Compiled output is re-added selectively below (jars / publish / bin).
272
+ if (framework === 'spring' || framework === 'java') extraExclude.add('target');
273
+ if (framework === 'dotnet') {
274
+ extraExclude.add('bin');
275
+ extraExclude.add('obj');
194
276
  }
195
277
 
278
+ await copyBackendSourceTree(cwd, stagingDir, extraExclude);
279
+
196
280
  await copyKubernetesManifestsIfPresent(cwd, stagingDir);
197
281
 
198
- await copyDirectoryIfExists(cwd, stagingDir, 'config');
199
- await copyDirectoryIfExists(cwd, stagingDir, 'migrations');
200
-
201
- if (['express', 'nestjs', 'fastify', 'koa', 'nextjs'].includes(framework)) {
202
- await copyIfExists(cwd, stagingDir, 'package.json');
203
- await copyIfExists(cwd, stagingDir, 'package-lock.json');
204
- } else if (['fastapi', 'django', 'flask', 'python'].includes(framework)) {
205
- // Source-only packages: root entrypoints + manifests (no dist/).
206
- await copyIfExists(cwd, stagingDir, 'requirements.txt');
207
- await copyIfExists(cwd, stagingDir, 'pyproject.toml');
208
- await copyIfExists(cwd, stagingDir, 'Pipfile');
209
- await copyIfExists(cwd, stagingDir, 'Pipfile.lock');
210
- await copyIfExists(cwd, stagingDir, 'setup.py');
211
- await copyIfExists(cwd, stagingDir, 'manage.py');
212
- await copyIfExists(cwd, stagingDir, 'main.py');
213
- await copyIfExists(cwd, stagingDir, 'app.py');
214
- await copyIfExists(cwd, stagingDir, 'wsgi.py');
215
- await copyIfExists(cwd, stagingDir, 'asgi.py');
216
- await copyDirectoryIfExists(cwd, stagingDir, 'app');
217
- await copyDirectoryIfExists(cwd, stagingDir, 'apps');
218
- // Django project package often sits next to manage.py (e.g. config/, mysite/).
219
- // `config/` is already copied above for all backends; also copy the detected
220
- // package that owns wsgi.py when it is not config/app/apps.
221
- if (framework === 'django') {
222
- await copyDirectoryIfExists(cwd, stagingDir, 'templates');
223
- await copyDirectoryIfExists(cwd, stagingDir, 'static');
224
- const wsgiPkg = detectDjangoWsgiPackageDir(cwd);
225
- if (
226
- wsgiPkg &&
227
- !['app', 'apps', 'config', 'templates', 'static', 'src'].includes(wsgiPkg)
228
- ) {
229
- await copyDirectoryIfExists(cwd, stagingDir, wsgiPkg);
282
+ if (['laravel', 'symfony', 'php'].includes(framework)) {
283
+ // storage/logs is written by php-fpm (www-data). Packing live logs is useless
284
+ // and unpacking them on the next deploy is a common permission-denied unzip.
285
+ const logsDir = path.join(stagingDir, 'storage', 'logs');
286
+ if (await fs.pathExists(logsDir)) {
287
+ const logFiles = await fs.readdir(logsDir);
288
+ for (const f of logFiles) {
289
+ if (f === '.gitignore') continue;
290
+ await fs.remove(path.join(logsDir, f));
230
291
  }
231
292
  }
232
- } else if (['laravel', 'symfony', 'php'].includes(framework)) {
233
- await copyIfExists(cwd, stagingDir, 'composer.json');
234
- await copyIfExists(cwd, stagingDir, 'composer.lock');
235
- await copyIfExists(cwd, stagingDir, 'artisan');
236
- await copyDirectoryIfExists(cwd, stagingDir, 'app');
237
- await copyDirectoryIfExists(cwd, stagingDir, 'bootstrap');
238
- await copyDirectoryIfExists(cwd, stagingDir, 'public');
239
- await copyDirectoryIfExists(cwd, stagingDir, 'routes');
240
- await copyDirectoryIfExists(cwd, stagingDir, 'database');
241
- await copyDirectoryIfExists(cwd, stagingDir, 'resources');
242
- await copyDirectoryIfExists(cwd, stagingDir, 'storage');
243
- await copyDirectoryIfExists(cwd, stagingDir, 'bin');
244
- } else if (framework === 'spring' || framework === 'java') {
245
- await copyIfExists(cwd, stagingDir, 'pom.xml');
293
+ }
294
+
295
+ if (framework === 'spring' || framework === 'java') {
246
296
  const targetDir = path.join(cwd, 'target');
247
297
  if (await fs.pathExists(targetDir)) {
248
298
  await fs.ensureDir(path.join(stagingDir, 'target'));
@@ -251,37 +301,20 @@ async function stageBackendArtifact(cwd, stagingDir, config) {
251
301
  await fs.copy(path.join(targetDir, jar), path.join(stagingDir, 'target', jar));
252
302
  }
253
303
  }
254
- } else if (framework === 'go') {
255
- await copyIfExists(cwd, stagingDir, 'go.mod');
256
- await copyIfExists(cwd, stagingDir, 'go.sum');
257
- await copyDirectoryIfExists(cwd, stagingDir, 'bin');
258
304
  } else if (framework === 'dotnet') {
259
- const files = await fs.readdir(cwd);
260
- for (const f of files.filter((name) => name.endsWith('.csproj'))) {
261
- await copyIfExists(cwd, stagingDir, f);
262
- }
263
305
  await copyDirectoryIfExists(cwd, stagingDir, settings.buildOutput || 'publish');
264
- } else if (framework === 'rails' || framework === 'ruby') {
265
- await copyIfExists(cwd, stagingDir, 'Gemfile');
266
- await copyIfExists(cwd, stagingDir, 'Gemfile.lock');
267
- await copyIfExists(cwd, stagingDir, 'config.ru');
268
- await copyIfExists(cwd, stagingDir, 'Rakefile');
269
- await copyDirectoryIfExists(cwd, stagingDir, 'app');
270
- await copyDirectoryIfExists(cwd, stagingDir, 'bin');
271
- await copyDirectoryIfExists(cwd, stagingDir, 'lib');
272
- await copyDirectoryIfExists(cwd, stagingDir, 'db');
273
- await copyDirectoryIfExists(cwd, stagingDir, 'public');
274
- } else {
275
- await copyIfExists(cwd, stagingDir, 'package.json');
276
- await copyIfExists(cwd, stagingDir, 'requirements.txt');
277
306
  }
278
307
 
279
- // Only copy a named build-output dir when it actually exists (compiled langs).
280
- // Skip '.' / 'src' — source is already staged above; never invent an empty dist/.
308
+ // Named build-output dir (nestjs dist/, etc.) when it exists and was not excluded.
281
309
  if (settings.buildOutput && settings.buildOutput !== '.' && settings.buildOutput !== 'src') {
282
310
  const built = path.join(cwd, settings.buildOutput);
283
- if (await fs.pathExists(built) && !['target', 'bin', 'publish'].includes(settings.buildOutput)) {
284
- await fs.copy(built, path.join(stagingDir, settings.buildOutput));
311
+ const staged = path.join(stagingDir, settings.buildOutput);
312
+ if (
313
+ (await fs.pathExists(built)) &&
314
+ !(await fs.pathExists(staged)) &&
315
+ !['target', 'bin', 'publish'].includes(settings.buildOutput)
316
+ ) {
317
+ await fs.copy(built, staged);
285
318
  }
286
319
  }
287
320
  }
@@ -417,18 +450,13 @@ ${getArtifactReadmeFooter()}`;
417
450
  const checksums = await generateChecksums(stagingDir);
418
451
  const checksumContent = formatChecksums(checksums);
419
452
  await fs.writeFile(path.join(artifactDir, 'checksums.txt'), checksumContent);
420
- await fs.writeFile(path.join(stagingDir, 'checksums.txt'), checksumContent);
421
453
  await fs.writeJson(
422
- path.join(stagingDir, 'deployment.json'),
454
+ path.join(artifactDir, 'deployment.json'),
423
455
  { targets: deployedTargets, deployedAt: timestamp },
424
456
  { spaces: 2 }
425
457
  );
426
- await fs.writeFile(
427
- path.join(stagingDir, 'release-notes.md'),
428
- await getReleaseNotes(cwd)
429
- );
430
458
 
431
- await fs.remove(stagingDir);
459
+ await removeStagingDir(stagingDir);
432
460
  log.success(`Artifact created at ${artifactDir}`);
433
461
 
434
462
  return { artifactDir, zipPath };
@@ -30,6 +30,16 @@ import {
30
30
  import { formatPasswordlessSudoGuidance } from '../utils/nginx.js';
31
31
  import { checkImagePullability } from '../utils/docker-image-deploy.js';
32
32
  import { namespaceExists } from '../utils/kubernetes-namespace.js';
33
+ import { resolvePhpVersion } from '../utils/php-version.js';
34
+ import {
35
+ buildPhpFpmUnitListCommand,
36
+ formatPhpFpmMissingError,
37
+ formatPhpFpmVersionMismatchError,
38
+ parsePhpFpmUnitList,
39
+ parsePhpMajorMinor,
40
+ pickPhpFpmUnitName,
41
+ preferredPhpFpmUnitName,
42
+ } from '../utils/php-fpm.js';
33
43
 
34
44
  /**
35
45
  * @typedef {{ name: string, pass: boolean, message: string }} CheckResult
@@ -634,7 +644,6 @@ async function runBackendProcessChecks(config, envName, deployType = 'ssh') {
634
644
 
635
645
  checks.push(
636
646
  await runCheck('pip', async () => {
637
- // Deploy runs `pip install -r requirements.txt`; accept pip3 or pip.
638
647
  const result = await provider.runRemoteCheck(
639
648
  'command -v pip3 >/dev/null 2>&1 || command -v pip >/dev/null 2>&1'
640
649
  );
@@ -651,24 +660,11 @@ async function runBackendProcessChecks(config, envName, deployType = 'ssh') {
651
660
  })
652
661
  );
653
662
 
654
- checks.push(
655
- await runCheck('gunicorn', async () => {
656
- const result = await provider.runRemoteCheck('which gunicorn || gunicorn --version');
657
- if (result.pass) {
658
- return { name: 'gunicorn', pass: true, message: 'gunicorn available' };
659
- }
660
- return {
661
- name: 'gunicorn',
662
- pass: false,
663
- message: 'not found — run: pip install gunicorn',
664
- };
665
- })
666
- );
667
-
663
+ // FastAPI SSH starts uvicorn, not gunicorn. Requiring gunicorn here is a false failure.
668
664
  if (framework === 'fastapi') {
669
665
  checks.push(
670
666
  await runCheck('uvicorn', async () => {
671
- const result = await provider.runRemoteCheck('which uvicorn || uvicorn --version');
667
+ const result = await provider.runRemoteCheck('command -v uvicorn >/dev/null 2>&1 || uvicorn --version');
672
668
  if (result.pass) {
673
669
  return { name: 'uvicorn', pass: true, message: 'uvicorn available' };
674
670
  }
@@ -679,29 +675,146 @@ async function runBackendProcessChecks(config, envName, deployType = 'ssh') {
679
675
  };
680
676
  })
681
677
  );
678
+ } else {
679
+ checks.push(
680
+ await runCheck('gunicorn', async () => {
681
+ const result = await provider.runRemoteCheck('command -v gunicorn >/dev/null 2>&1 || gunicorn --version');
682
+ if (result.pass) {
683
+ return { name: 'gunicorn', pass: true, message: 'gunicorn available' };
684
+ }
685
+ return {
686
+ name: 'gunicorn',
687
+ pass: false,
688
+ message: 'not found — run: pip install gunicorn',
689
+ };
690
+ })
691
+ );
682
692
  }
683
693
  }
684
694
 
685
695
  if (PHP_FRAMEWORKS.has(framework)) {
696
+ const phpVersion = resolvePhpVersion(config);
697
+ const preferredUnit = preferredPhpFpmUnitName(phpVersion);
698
+
699
+ checks.push(
700
+ await runCheck('PHP CLI', async () => {
701
+ const result = await provider.runRemoteCheck('command -v php >/dev/null 2>&1 && php -v');
702
+ if (!result.pass) {
703
+ return {
704
+ name: 'PHP CLI',
705
+ pass: false,
706
+ message:
707
+ `php not found on PATH — install PHP ${phpVersion} CLI on the server ` +
708
+ `(needed for composer / artisan during SSH deploy)`,
709
+ };
710
+ }
711
+ const remoteMm = parsePhpMajorMinor(result.message);
712
+ if (remoteMm && remoteMm !== phpVersion) {
713
+ return {
714
+ name: 'PHP CLI',
715
+ pass: false,
716
+ message:
717
+ `php on server reports ${remoteMm} (from php -v), but this project expects ${phpVersion} ` +
718
+ `(phpVersion / backend.phpVersion / default). Install matching PHP or update deployhub.config.json.`,
719
+ };
720
+ }
721
+ return {
722
+ name: 'PHP CLI',
723
+ pass: true,
724
+ message: remoteMm
725
+ ? `php ${remoteMm} found on PATH (matches project)`
726
+ : `php found on PATH (${result.message.split('\n')[0] || 'ok'})`,
727
+ };
728
+ })
729
+ );
730
+
731
+ checks.push(
732
+ await runCheck('Composer', async () => {
733
+ const result = await provider.runRemoteCheck('command -v composer');
734
+ if (result.pass) {
735
+ return { name: 'Composer', pass: true, message: 'composer found on PATH' };
736
+ }
737
+ return {
738
+ name: 'Composer',
739
+ pass: false,
740
+ message:
741
+ 'composer not found on PATH — install Composer on the server ' +
742
+ '(SSH deploy runs `composer install --no-dev` after extract)',
743
+ };
744
+ })
745
+ );
746
+
686
747
  checks.push(
687
748
  await runCheck('php-fpm', async () => {
688
- const result = await provider.runRemoteCheck(
689
- 'systemctl is-active php8.2-fpm || systemctl is-active php-fpm'
749
+ const listed = await provider.runRemoteCheck(buildPhpFpmUnitListCommand());
750
+ const units = parsePhpFpmUnitList(listed.message || '');
751
+ const pick = pickPhpFpmUnitName(units, phpVersion);
752
+
753
+ if (!pick) {
754
+ return {
755
+ name: 'php-fpm',
756
+ pass: false,
757
+ message: formatPhpFpmMissingError(phpVersion, units),
758
+ };
759
+ }
760
+
761
+ if (pick.match === 'other-version') {
762
+ return {
763
+ name: 'php-fpm',
764
+ pass: false,
765
+ message: formatPhpFpmVersionMismatchError(phpVersion, pick.unit),
766
+ };
767
+ }
768
+
769
+ const active = await provider.runRemoteCheck(
770
+ `sudo -n systemctl is-active ${pick.unit}`
690
771
  );
691
- if (result.pass && result.message.includes('active')) {
692
- return { name: 'php-fpm', pass: true, message: 'php-fpm running' };
772
+ if (active.pass && String(active.message).includes('active')) {
773
+ const note =
774
+ pick.match === 'generic'
775
+ ? ` (generic php-fpm; preferred ${preferredUnit} not installed — OK on Amazon Linux/RHEL)`
776
+ : '';
777
+ return {
778
+ name: 'php-fpm',
779
+ pass: true,
780
+ message: `${pick.unit} running${note}`,
781
+ };
693
782
  }
694
- return { name: 'php-fpm', pass: false, message: 'php-fpm not running' };
783
+
784
+ return {
785
+ name: 'php-fpm',
786
+ pass: false,
787
+ message:
788
+ `${pick.unit} is installed but not active (systemctl is-active: ${active.message}). ` +
789
+ `Start it with: sudo systemctl enable --now ${pick.unit}`,
790
+ };
695
791
  })
696
792
  );
697
793
 
698
794
  checks.push(
699
795
  await runCheck('nginx', async () => {
700
- const result = await provider.runRemoteCheck('systemctl is-active nginx');
796
+ const installed = await provider.runRemoteCheck(
797
+ 'command -v nginx >/dev/null 2>&1 && echo yes'
798
+ );
799
+ if (!installed.pass || !String(installed.message).includes('yes')) {
800
+ return {
801
+ name: 'nginx',
802
+ pass: false,
803
+ message:
804
+ 'nginx not found on PATH — install it (Amazon Linux: sudo dnf install -y nginx; Ubuntu: sudo apt install nginx), then: sudo systemctl enable --now nginx',
805
+ };
806
+ }
807
+ const result = await provider.runRemoteCheck('sudo -n systemctl is-active nginx');
701
808
  if (result.pass && result.message.includes('active')) {
702
809
  return { name: 'nginx', pass: true, message: 'nginx running' };
703
810
  }
704
- return { name: 'nginx', pass: false, message: 'nginx not running' };
811
+ return {
812
+ name: 'nginx',
813
+ pass: false,
814
+ message:
815
+ `nginx is installed but not active (systemctl is-active: ${result.message}). ` +
816
+ 'Start it with: sudo systemctl enable --now nginx',
817
+ };
705
818
  })
706
819
  );
707
820
  }
@@ -727,6 +840,53 @@ async function runBackendProcessChecks(config, envName, deployType = 'ssh') {
727
840
  );
728
841
  }
729
842
 
843
+ if (framework === 'dotnet') {
844
+ checks.push(
845
+ await runCheck('.NET runtime', async () => {
846
+ const result = await provider.runRemoteCheck('dotnet --version');
847
+ if (result.pass) {
848
+ return { name: '.NET runtime', pass: true, message: `dotnet ${result.message.split('\n')[0] || 'found'}` };
849
+ }
850
+ return {
851
+ name: '.NET runtime',
852
+ pass: false,
853
+ message:
854
+ 'dotnet not found on PATH — install the ASP.NET Core runtime on the server ' +
855
+ '(SSH deploy runs `dotnet <dll>` after extract)',
856
+ };
857
+ })
858
+ );
859
+ }
860
+
861
+ if (framework === 'rails' || framework === 'ruby') {
862
+ checks.push(
863
+ await runCheck('Ruby', async () => {
864
+ const result = await provider.runRemoteCheck('command -v ruby >/dev/null 2>&1 && ruby -v');
865
+ if (result.pass) {
866
+ return { name: 'Ruby', pass: true, message: result.message.split('\n')[0] || 'ruby found' };
867
+ }
868
+ return {
869
+ name: 'Ruby',
870
+ pass: false,
871
+ message: 'ruby not found on PATH — install Ruby 3.2+ on the server (SSH deploy runs bundle install)',
872
+ };
873
+ })
874
+ );
875
+ checks.push(
876
+ await runCheck('Bundler', async () => {
877
+ const result = await provider.runRemoteCheck('command -v bundle');
878
+ if (result.pass) {
879
+ return { name: 'Bundler', pass: true, message: 'bundle found on PATH' };
880
+ }
881
+ return {
882
+ name: 'Bundler',
883
+ pass: false,
884
+ message: 'bundle not found on PATH — run: gem install bundler',
885
+ };
886
+ })
887
+ );
888
+ }
889
+
730
890
  return checks;
731
891
  }
732
892
 
@@ -55,7 +55,7 @@ export const SSH_BACKEND_ENV_VARS = [
55
55
  {
56
56
  key: 'SSH_APP_NAME',
57
57
  comment: [
58
- 'Process name used by PM2 when restarting your Node.js backend.',
58
+ 'Env-scoped process identity: PM2 app name (Node), DEPLOYHUB_APP / PID markers (Python, Java, Go, .NET, Rails), and related resource names. Not the php-fpm systemd unit.',
59
59
  ],
60
60
  example: 'my-api',
61
61
  when: 'backend',
@@ -143,7 +143,9 @@ export async function validateSshKeyForDoctor(keyPath, sshKey) {
143
143
  }
144
144
 
145
145
  const mode = stat.mode & 0o777;
146
- if (mode !== 0o400 && mode !== 0o600) {
146
+ // Windows NTFS does not carry Unix 400/600; OpenSSH uses ACLs instead.
147
+ // A 666 stat here is not "world-readable" the way it is on Linux.
148
+ if (process.platform !== 'win32' && mode !== 0o400 && mode !== 0o600) {
147
149
  return {
148
150
  ok: false,
149
151
  message: `SSH key permissions are ${mode.toString(8)} (should be 400 or 600) — run: chmod 600 ${resolved}`,
@@ -13,6 +13,15 @@ import {
13
13
  import { resolvePm2AppName } from '../../utils/pm2-app-name.js';
14
14
  import { shellQuote, formatRemoteCommandFailure } from '../../utils/shell-quote.js';
15
15
  import { extractGunicornTarget } from '../../utils/python-app-target.js';
16
+ import { resolvePhpVersion } from '../../utils/php-version.js';
17
+ import {
18
+ buildPhpFpmUnitListCommand,
19
+ formatPhpFpmMissingError,
20
+ formatPhpFpmVersionMismatchError,
21
+ parsePhpFpmUnitList,
22
+ pickPhpFpmUnitName,
23
+ preferredPhpFpmUnitName,
24
+ } from '../../utils/php-fpm.js';
16
25
 
17
26
  /** @type {Set<string>} */
18
27
  const NODE_FRAMEWORKS = new Set(['express', 'nestjs', 'fastify', 'koa', 'nextjs', 'node']);
@@ -389,13 +398,24 @@ export function createSshProvider(config, envName, env = process.env) {
389
398
  if (PHP_FRAMEWORKS.has(framework)) {
390
399
  // PHP uses a host-wide `systemctl restart php*-fpm` (see README PHP warning).
391
400
  // Per-env isolation is Nginx site name + deploy path — not automated FPM pools.
401
+ // startCommand (e.g. php artisan serve) is intentionally unused on SSH — FPM+nginx only.
402
+ const phpVersion = resolvePhpVersion(config);
403
+ const preferredUnit = preferredPhpFpmUnitName(phpVersion);
404
+ log.info(
405
+ `PHP backend detected — using php-fpm+nginx (prefer ${preferredUnit}, else php-fpm); ` +
406
+ `startCommand is not used for this method`
407
+ );
408
+
392
409
  await exec(ssh, `cd ${dir} && composer install --no-dev`);
393
410
  if (framework === 'laravel') {
394
411
  await exec(ssh, `cd ${dir} && php artisan migrate --force`);
395
412
  await exec(ssh, `cd ${dir} && php artisan config:cache`);
396
413
  }
397
- await exec(ssh, 'sudo systemctl restart php8.2-fpm');
398
- await exec(ssh, 'sudo systemctl reload nginx');
414
+
415
+ const fpmUnit = await resolveRemotePhpFpmUnit(ssh, phpVersion);
416
+ log.info(`Restarting PHP-FPM service: ${fpmUnit}`);
417
+ await exec(ssh, `sudo systemctl restart ${sh(fpmUnit)}`);
418
+ await reloadNginx(ssh);
399
419
  return;
400
420
  }
401
421
 
@@ -421,8 +441,13 @@ export function createSshProvider(config, envName, env = process.env) {
421
441
 
422
442
  if (framework === 'dotnet') {
423
443
  await stopScopedBackendProcess(ssh, targetPath);
424
- const dll = startCommand?.replace('dotnet ', '') || 'App.dll';
425
- await startScopedNohup(ssh, targetPath, `dotnet ${dll}`);
444
+ // Discover the published DLL at runtime — csproj name is not always App.dll
445
+ // (same class of bug as the Docker CMD ["dotnet","App.dll"] hardcode).
446
+ await startScopedNohup(
447
+ ssh,
448
+ targetPath,
449
+ `sh -c 'dll=$(ls -1 *.dll 2>/dev/null | head -n1); test -n "$dll" || { echo "No .dll found in $(pwd) for .NET start" >&2; exit 1; }; exec dotnet "$dll"'`
450
+ );
426
451
  return;
427
452
  }
428
453
 
@@ -441,6 +466,38 @@ export function createSshProvider(config, envName, env = process.env) {
441
466
  await exec(ssh, 'pm2 save');
442
467
  }
443
468
 
469
+ /**
470
+ * Resolve the php-fpm systemd unit on the remote host.
471
+ * Prefers php{version}-fpm (Debian/Ubuntu), then php-fpm (RHEL/Amazon Linux).
472
+ * Throws if nothing usable is installed — never restarts a guessed missing unit.
473
+ *
474
+ * @param {import('node-ssh').NodeSSH} ssh
475
+ * @param {string} phpVersion
476
+ * @returns {Promise<string>}
477
+ */
478
+ async function resolveRemotePhpFpmUnit(ssh, phpVersion) {
479
+ const listCmd = buildPhpFpmUnitListCommand();
480
+ const listed = await ssh.execCommand(listCmd);
481
+ const units = parsePhpFpmUnitList(listed.stdout || '');
482
+ const pick = pickPhpFpmUnitName(units, phpVersion);
483
+
484
+ if (pick?.match === 'exact' || pick?.match === 'generic') {
485
+ if (pick.match === 'generic') {
486
+ log.info(
487
+ `Preferred ${preferredPhpFpmUnitName(phpVersion)} not installed; ` +
488
+ `using generic php-fpm (typical on Amazon Linux/RHEL)`
489
+ );
490
+ }
491
+ return pick.unit;
492
+ }
493
+
494
+ if (pick?.match === 'other-version') {
495
+ throw new Error(formatPhpFpmVersionMismatchError(phpVersion, pick.unit));
496
+ }
497
+
498
+ throw new Error(formatPhpFpmMissingError(phpVersion, units));
499
+ }
500
+
444
501
  /**
445
502
  * @param {import('node-ssh').NodeSSH} ssh
446
503
  * @param {string} remotePath
@@ -524,13 +581,35 @@ export function createSshProvider(config, envName, env = process.env) {
524
581
  log.success('Nginx config tested and reloaded');
525
582
  }
526
583
 
584
+ /**
585
+ * Make an existing remote directory writable by the SSH user so the next
586
+ * unzip/rsync is not blocked by www-data ownership from php-fpm or nginx.
587
+ * @param {import('node-ssh').NodeSSH} ssh
588
+ * @param {string} targetPath
589
+ */
590
+ async function ensureWritableDeployDir(ssh, targetPath) {
591
+ const targetQ = sh(targetPath);
592
+ const userQ = sh(user);
593
+ await exec(ssh, `mkdir -p ${targetQ}`);
594
+ await exec(
595
+ ssh,
596
+ `if [ -d ${targetQ} ]; then ` +
597
+ `chmod -R u+w ${targetQ} 2>/dev/null || true; ` +
598
+ `if [ ! -w ${targetQ} ]; then ` +
599
+ `sudo chown -R ${userQ}:${userQ} ${targetQ} 2>/dev/null || true; ` +
600
+ `chmod -R u+w ${targetQ} 2>/dev/null || true; ` +
601
+ `fi; ` +
602
+ `fi`
603
+ );
604
+ }
605
+
527
606
  /**
528
607
  * @param {import('node-ssh').NodeSSH} ssh
529
608
  * @param {string} remoteZip
530
609
  * @param {string} targetPath
531
610
  */
532
611
  async function extractToPath(ssh, remoteZip, targetPath) {
533
- await exec(ssh, `mkdir -p ${sh(targetPath)}`);
612
+ await ensureWritableDeployDir(ssh, targetPath);
534
613
  await exec(ssh, `unzip -o ${sh(remoteZip)} -d ${sh(targetPath)}`);
535
614
  }
536
615
 
@@ -554,13 +633,13 @@ export function createSshProvider(config, envName, env = process.env) {
554
633
  await exec(ssh, `mkdir -p ${sh(remoteStaging)}`);
555
634
  await exec(ssh, `unzip -o ${sh(remoteZip)} -d ${sh(remoteStaging)}`);
556
635
 
557
- await exec(ssh, `mkdir -p ${sh(frontendDeployPath)}`);
636
+ await ensureWritableDeployDir(ssh, frontendDeployPath);
558
637
  await exec(
559
638
  ssh,
560
639
  `rsync -a ${sh(remoteStaging)}/ ${sh(frontendDeployPath)}/ --exclude backend || cp -r ${sh(remoteStaging)}/* ${sh(frontendDeployPath)}/`
561
640
  );
562
641
 
563
- await exec(ssh, `mkdir -p ${sh(backendDeployPath)}`);
642
+ await ensureWritableDeployDir(ssh, backendDeployPath);
564
643
  await exec(
565
644
  ssh,
566
645
  `rsync -a ${sh(remoteStaging)}/backend/ ${sh(backendDeployPath)}/ || cp -r ${sh(remoteStaging)}/backend/* ${sh(backendDeployPath)}/`
@@ -67,7 +67,7 @@ const FRAMEWORKS = {
67
67
  language: 'node',
68
68
  buildCommand: 'npm run build',
69
69
  startCommand: null,
70
- buildOutput: 'public',
70
+ buildOutput: 'dist',
71
71
  testCommand: 'npm test',
72
72
  port: 80,
73
73
  },
@@ -174,7 +174,7 @@ WORKDIR /app
174
174
  COPY ${dir}/ .
175
175
  EXPOSE ${port}
176
176
  ENV ASPNETCORE_URLS=http://+:${port}
177
- CMD ["dotnet", "App.dll"]
177
+ CMD ["sh", "-c", "dll=$(ls *.dll 2>/dev/null | head -n1); exec dotnet \\"$dll\\""]
178
178
  `;
179
179
  }
180
180
 
@@ -191,7 +191,7 @@ export function isFrontendStaticFramework(framework) {
191
191
  * @param {string} framework
192
192
  */
193
193
  export function isNodeBackendFramework(framework) {
194
- return ['express', 'nestjs', 'fastify', 'koa', 'nextjs'].includes(framework || '');
194
+ return ['express', 'nestjs', 'fastify', 'koa', 'nextjs', 'node'].includes(framework || '');
195
195
  }
196
196
 
197
197
  /**
@@ -201,7 +201,7 @@ export function isNodeBackendFramework(framework) {
201
201
  */
202
202
  export function isInterpretedBackendFramework(framework) {
203
203
  return [
204
- ...['express', 'nestjs', 'fastify', 'koa', 'nextjs'],
204
+ ...['express', 'nestjs', 'fastify', 'koa', 'nextjs', 'node'],
205
205
  'fastapi',
206
206
  'django',
207
207
  'flask',
@@ -2,6 +2,8 @@
2
2
  * Dockerfile generation based on detected framework / project settings.
3
3
  */
4
4
 
5
+ import { resolvePhpVersion } from './php-version.js';
6
+
5
7
  const GENERATED_HEADER =
6
8
  '# Generated by DeployHub — review exposed port and start command before deploying.\n';
7
9
 
@@ -18,10 +20,11 @@ export function sanitizeDockerProjectName(projectName) {
18
20
 
19
21
  /**
20
22
  * @param {import('../core/config.js').DeployHubConfig} config
21
- * @returns {{ framework: string, buildCommand: string|null, buildOutput: string, startCommand: string|null, port: number, projectType: string }}
23
+ * @returns {{ framework: string, buildCommand: string|null, buildOutput: string, startCommand: string|null, port: number, projectType: string, phpVersion: string }}
22
24
  */
23
25
  export function resolveDockerSettings(config) {
24
26
  const projectType = config.projectType || 'frontend';
27
+ const phpVersion = resolvePhpVersion(config);
25
28
 
26
29
  if (projectType === 'both' && config.backend) {
27
30
  const framework = config.backend.framework || 'express';
@@ -33,6 +36,7 @@ export function resolveDockerSettings(config) {
33
36
  startCommand: config.backend.startCommand ?? null,
34
37
  port: config.backend.port || defaultPort(framework, projectType),
35
38
  projectType,
39
+ phpVersion,
36
40
  };
37
41
  }
38
42
 
@@ -45,6 +49,7 @@ export function resolveDockerSettings(config) {
45
49
  startCommand: config.startCommand ?? null,
46
50
  port: config.port || defaultPort(framework, projectType),
47
51
  projectType,
52
+ phpVersion,
48
53
  };
49
54
  }
50
55
 
@@ -71,6 +76,8 @@ function defaultBuildOutput(framework, projectType) {
71
76
  * @param {string} projectType
72
77
  */
73
78
  function defaultPort(framework, projectType) {
79
+ // Next.js is a Node server, not nginx static — even when projectType is frontend.
80
+ if (framework === 'nextjs') return 3000;
74
81
  if (
75
82
  projectType === 'frontend' ||
76
83
  ['react', 'vue', 'angular', 'svelte', 'astro', 'vanilla'].includes(framework)
@@ -239,9 +246,21 @@ export function generateDockerfile(config) {
239
246
  * @param {{ buildCommand: string|null, buildOutput: string, port: number }} settings
240
247
  */
241
248
  function generateFrontendStaticDockerfile(settings) {
242
- const buildCmd = settings.buildCommand || 'npm run build';
249
+ const buildCmd = settings.buildCommand;
243
250
  const output = settings.buildOutput || 'dist';
244
251
 
252
+ // Vanilla / prebuilt static trees have no compile step. Do not invent
253
+ // `npm run build` — that fails when package.json has no build script.
254
+ if (!buildCmd || String(buildCmd).trim() === '') {
255
+ const src = output === '.' ? '.' : output;
256
+ return `${GENERATED_HEADER}
257
+ FROM nginx:alpine
258
+ COPY ${src} /usr/share/nginx/html
259
+ EXPOSE 80
260
+ CMD ["nginx", "-g", "daemon off;"]
261
+ `;
262
+ }
263
+
245
264
  return `${GENERATED_HEADER}
246
265
  FROM node:20-alpine AS build
247
266
  WORKDIR /app
@@ -276,6 +295,7 @@ FROM node:20-alpine AS build
276
295
  WORKDIR /app
277
296
  COPY --from=deps /app/node_modules ./node_modules
278
297
  COPY . .
298
+ RUN mkdir -p public
279
299
  RUN ${buildCmd}
280
300
 
281
301
  FROM node:20-alpine AS runner
@@ -448,19 +468,19 @@ function generatePythonDockerfile(settings) {
448
468
  return `${GENERATED_HEADER}
449
469
  FROM python:3.11-slim
450
470
  WORKDIR /app
451
- COPY requirements.txt* ./
452
- RUN if [ -f requirements.txt ]; then pip install --no-cache-dir -r requirements.txt; fi
453
471
  COPY . .
472
+ RUN if [ -f requirements.txt ]; then pip install --no-cache-dir -r requirements.txt; fi
454
473
  EXPOSE ${port}
455
474
  CMD ${JSON.stringify(startParts)}
456
475
  `;
457
476
  }
458
477
 
459
478
  /**
460
- * @param {{ port: number }} settings
479
+ * @param {{ port: number, phpVersion?: string }} settings
461
480
  */
462
481
  function generateLaravelDockerfile(settings) {
463
482
  const port = settings.port || 80;
483
+ const phpVersion = settings.phpVersion || resolvePhpVersion();
464
484
  // Single-process HTTP for container/K8s fitness. SSH deploys still use host
465
485
  // php-fpm + nginx; this image must listen on EXPOSE (FPM alone listens on 9000).
466
486
  const cmd = JSON.stringify([
@@ -474,13 +494,15 @@ function generateLaravelDockerfile(settings) {
474
494
  // Vendor stage copies only composer.json/lock for layer caching. Laravel's
475
495
  // post-autoload-dump runs `php artisan package:discover`, which needs the
476
496
  // full app — so install with --no-scripts here, then dump-autoload after COPY.
497
+ // --ignore-platform-reqs stays: composer:2 may ship a different PHP than the
498
+ // runtime image (phpVersion is applied only to the final stage below).
477
499
  return `${GENERATED_HEADER}
478
500
  FROM composer:2 AS vendor
479
501
  WORKDIR /app
480
- COPY composer.json composer.lock* ./
502
+ COPY composer.json ./
481
503
  RUN composer install --no-dev --optimize-autoloader --no-interaction --ignore-platform-reqs --no-scripts
482
504
 
483
- FROM php:8.2-cli-alpine
505
+ FROM php:${phpVersion}-cli-alpine
484
506
  WORKDIR /var/www/html
485
507
  COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
486
508
  RUN apk add --no-cache icu-libs libzip \\
@@ -497,10 +519,11 @@ CMD ${cmd}
497
519
  }
498
520
 
499
521
  /**
500
- * @param {{ port: number }} settings
522
+ * @param {{ port: number, phpVersion?: string }} settings
501
523
  */
502
524
  function generateSymfonyDockerfile(settings) {
503
525
  const port = settings.port || 80;
526
+ const phpVersion = settings.phpVersion || resolvePhpVersion();
504
527
  // Built-in server binds HTTP to EXPOSE — suitable for a single-container pod.
505
528
  // Prefer a production reverse-proxy image if you outgrow this starter template.
506
529
  const cmd = JSON.stringify(['php', '-S', `0.0.0.0:${port}`, '-t', 'public']);
@@ -510,10 +533,10 @@ function generateSymfonyDockerfile(settings) {
510
533
  return `${GENERATED_HEADER}
511
534
  FROM composer:2 AS vendor
512
535
  WORKDIR /app
513
- COPY composer.json composer.lock* ./
536
+ COPY composer.json ./
514
537
  RUN composer install --no-dev --optimize-autoloader --no-interaction --ignore-platform-reqs --no-scripts
515
538
 
516
- FROM php:8.2-cli-alpine
539
+ FROM php:${phpVersion}-cli-alpine
517
540
  WORKDIR /var/www/html
518
541
  COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
519
542
  RUN apk add --no-cache icu-libs libzip \\
@@ -531,10 +554,11 @@ CMD ${cmd}
531
554
  /**
532
555
  * Plain PHP (no Laravel/Symfony): single-process built-in server.
533
556
  * Docroot prefers public/ at runtime when present; optional composer install.
534
- * @param {{ port: number }} settings
557
+ * @param {{ port: number, phpVersion?: string }} settings
535
558
  */
536
559
  function generatePhpDockerfile(settings) {
537
560
  const port = settings.port || 80;
561
+ const phpVersion = settings.phpVersion || resolvePhpVersion();
538
562
  const cmd = JSON.stringify([
539
563
  'sh',
540
564
  '-c',
@@ -542,7 +566,7 @@ function generatePhpDockerfile(settings) {
542
566
  ]);
543
567
 
544
568
  return `${GENERATED_HEADER}
545
- FROM php:8.2-cli-alpine
569
+ FROM php:${phpVersion}-cli-alpine
546
570
  WORKDIR /var/www/html
547
571
  COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
548
572
  RUN apk add --no-cache icu-libs libzip \\
@@ -590,7 +614,7 @@ function generateGoDockerfile(settings) {
590
614
  return `${GENERATED_HEADER}
591
615
  FROM golang:1.22-alpine AS build
592
616
  WORKDIR /app
593
- COPY go.mod go.sum* ./
617
+ COPY go.mod ./
594
618
  RUN go mod download
595
619
  COPY . .
596
620
  RUN mkdir -p /app/bin && ${buildCmd}
@@ -612,6 +636,11 @@ function generateDotnetDockerfile(settings) {
612
636
  const buildCmd = settings.buildCommand || 'dotnet publish -c Release -o publish';
613
637
  const port = settings.port || 5000;
614
638
  const output = settings.buildOutput || 'publish';
639
+ const startParts = [
640
+ 'sh',
641
+ '-c',
642
+ 'dll=$(ls *.dll 2>/dev/null | head -n1); exec dotnet "$dll"',
643
+ ];
615
644
 
616
645
  return `${GENERATED_HEADER}
617
646
  FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
@@ -626,7 +655,7 @@ WORKDIR /app
626
655
  COPY --from=build /src/${output} .
627
656
  EXPOSE ${port}
628
657
  ENV ASPNETCORE_URLS=http://+:${port}
629
- CMD ["dotnet", "App.dll"]
658
+ CMD ${JSON.stringify(startParts)}
630
659
  `;
631
660
  }
632
661
 
@@ -644,7 +673,7 @@ function generateRailsDockerfile(settings) {
644
673
  FROM ruby:3.2-slim AS build
645
674
  WORKDIR /app
646
675
  RUN apt-get update -qq && apt-get install -y build-essential libpq-dev && rm -rf /var/lib/apt/lists/*
647
- COPY Gemfile Gemfile.lock* ./
676
+ COPY Gemfile ./
648
677
  RUN bundle config set --local without 'development test' && bundle install
649
678
  COPY . .
650
679
  RUN bundle exec rake assets:precompile || true
@@ -673,7 +702,7 @@ function generateRubyDockerfile(settings) {
673
702
  FROM ruby:3.2-slim
674
703
  WORKDIR /app
675
704
  RUN apt-get update -qq && apt-get install -y build-essential && rm -rf /var/lib/apt/lists/*
676
- COPY Gemfile Gemfile.lock* ./
705
+ COPY Gemfile ./
677
706
  RUN bundle config set --local without 'development test' && bundle install
678
707
  COPY . .
679
708
  EXPOSE ${port}
@@ -22,9 +22,9 @@ import {
22
22
  getEnabledEnvironmentNames,
23
23
  isEnvEnabled,
24
24
  } from '../core/environments.js';
25
+ import { resolvePhpVersion } from './php-version.js';
25
26
 
26
- /** Default PHP for CI when config does not set `phpVersion` / `backend.phpVersion`. */
27
- export const DEFAULT_PHP_VERSION = '8.4';
27
+ export { DEFAULT_PHP_VERSION, resolvePhpVersion } from './php-version.js';
28
28
 
29
29
  /** @typedef {'aws'|'azure'|'gcp'|'gdrive'|'dropbox'|'local'|'ftp'|'ssh'} ProviderEnvKey */
30
30
 
@@ -271,27 +271,6 @@ function getGithubGitConfigStep() {
271
271
  fi`;
272
272
  }
273
273
 
274
- /**
275
- * Resolve PHP version for CI setup-php.
276
- * Order: `backend.phpVersion` → top-level `phpVersion` → {@link DEFAULT_PHP_VERSION} (`8.4`).
277
- * Set either config key in deployhub.config.json to pin a different runtime
278
- * (e.g. `"phpVersion": "8.3"` or `"backend": { "phpVersion": "8.3" }`).
279
- *
280
- * @param {import('../core/config.js').DeployHubConfig} [config]
281
- * @returns {string}
282
- */
283
- export function resolvePhpVersion(config) {
284
- const fromBackend = config?.backend?.phpVersion;
285
- if (typeof fromBackend === 'string' && fromBackend.trim()) {
286
- return fromBackend.trim();
287
- }
288
- const fromRoot = config?.phpVersion;
289
- if (typeof fromRoot === 'string' && fromRoot.trim()) {
290
- return fromRoot.trim();
291
- }
292
- return DEFAULT_PHP_VERSION;
293
- }
294
-
295
274
  /**
296
275
  * @param {import('../core/config.js').DeployHubConfig} [config]
297
276
  * @returns {boolean}
@@ -884,12 +863,10 @@ ${rollbackRun}
884
863
  * @param {import('../core/config.js').DeployHubConfig} [config]
885
864
  * @returns {string}
886
865
  */
887
- function getInstallDepsCommand(config) {
888
- if (!config) return 'npm install';
889
-
866
+ function getBackendInstallDepsCommand(config) {
890
867
  const framework =
891
- config.backend?.framework || config.framework || 'express';
892
- const language = config.backend?.language || config.language;
868
+ config?.backend?.framework || config?.framework || 'express';
869
+ const language = config?.backend?.language || config?.language;
893
870
 
894
871
  if (language === 'python' || ['fastapi', 'django', 'flask', 'python'].includes(framework)) {
895
872
  return 'pip install -r requirements.txt';
@@ -912,6 +889,25 @@ function getInstallDepsCommand(config) {
912
889
  return 'npm install';
913
890
  }
914
891
 
892
+ /**
893
+ * @param {import('../core/config.js').DeployHubConfig} [config]
894
+ * @returns {string}
895
+ */
896
+ function getInstallDepsCommand(config) {
897
+ if (!config) return 'npm install';
898
+
899
+ const projectType = config.projectType || 'frontend';
900
+ if (projectType === 'frontend') return 'npm install';
901
+
902
+ const backendCmd = getBackendInstallDepsCommand(config);
903
+ // Fullstack: frontend is always Node in this CLI. Backend install alone
904
+ // (composer/pip/…) leaves the SPA without node_modules and `npm run build` dies.
905
+ if (projectType === 'both' && backendCmd !== 'npm install') {
906
+ return `npm install && ${backendCmd}`;
907
+ }
908
+ return backendCmd;
909
+ }
910
+
915
911
  /**
916
912
  * Write deployhub.yml and deployhub-rollback.yml from the same secret/env helpers.
917
913
  *
@@ -0,0 +1,135 @@
1
+ /**
2
+ * PHP-FPM systemd unit naming for SSH deploys / doctor.
3
+ * Debian/Ubuntu: php8.4-fpm; Amazon Linux/RHEL: often plain php-fpm.
4
+ */
5
+
6
+ import { resolvePhpVersion } from './php-version.js';
7
+
8
+ /**
9
+ * @param {string} phpVersion — e.g. "8.4"
10
+ * @returns {string} — e.g. "php8.4-fpm"
11
+ */
12
+ export function preferredPhpFpmUnitName(phpVersion) {
13
+ const v = String(phpVersion || '').trim();
14
+ return `php${v}-fpm`;
15
+ }
16
+
17
+ /**
18
+ * Shell command that lists installed php-fpm unit basenames (one per line).
19
+ * Uses unit-files so inactive-but-installed units still appear.
20
+ * @returns {string}
21
+ */
22
+ export function buildPhpFpmUnitListCommand() {
23
+ // sudo -n: unprivileged SSH users cannot talk to systemd's private bus
24
+ // (Failed to connect to bus) inside containers and some hardened hosts.
25
+ return (
26
+ `sudo -n systemctl list-unit-files --type=service --no-legend ` +
27
+ `'php*-fpm.service' 'php-fpm.service' 2>/dev/null ` +
28
+ `| awk '{print $1}' | sed 's/\\.service$//' | sort -u`
29
+ );
30
+ }
31
+
32
+ /**
33
+ * @param {string} stdout
34
+ * @returns {string[]}
35
+ */
36
+ export function parsePhpFpmUnitList(stdout) {
37
+ if (!stdout || typeof stdout !== 'string') return [];
38
+ /** @type {Set<string>} */
39
+ const units = new Set();
40
+ for (const line of stdout.split(/\r?\n/)) {
41
+ const raw = line.trim();
42
+ if (!raw) continue;
43
+ // list-unit-files: "php8.4-fpm.service enabled" — first column only
44
+ const first = raw.split(/\s+/)[0] || '';
45
+ const name = first.replace(/\.service$/i, '');
46
+ if (!name) continue;
47
+ if (name === 'php-fpm' || /^php[\d.]+-fpm$/i.test(name)) {
48
+ units.add(name === 'php-fpm' ? 'php-fpm' : name);
49
+ }
50
+ }
51
+ return [...units];
52
+ }
53
+
54
+
55
+ /**
56
+ * @typedef {{ unit: string, match: 'exact'|'generic'|'other-version' }} PhpFpmUnitPick
57
+ */
58
+
59
+ /**
60
+ * Prefer versioned unit (Debian/Ubuntu), then generic php-fpm (RHEL/AL),
61
+ * else surface another versioned unit as a mismatch (do not silently use it
62
+ * for restart — caller decides whether to error).
63
+ *
64
+ * @param {string[]} availableUnits
65
+ * @param {string} phpVersion
66
+ * @returns {PhpFpmUnitPick | null}
67
+ */
68
+ export function pickPhpFpmUnitName(availableUnits, phpVersion) {
69
+ const preferred = preferredPhpFpmUnitName(phpVersion);
70
+ const normalized = availableUnits.map((u) => u.trim()).filter(Boolean);
71
+ if (normalized.includes(preferred)) {
72
+ return { unit: preferred, match: 'exact' };
73
+ }
74
+ if (normalized.includes('php-fpm')) {
75
+ return { unit: 'php-fpm', match: 'generic' };
76
+ }
77
+ const other = normalized.find((u) => /^php[\d.]+-fpm$/i.test(u));
78
+ if (other) {
79
+ return { unit: other, match: 'other-version' };
80
+ }
81
+ return null;
82
+ }
83
+
84
+ /**
85
+ * @param {import('../core/config.js').DeployHubConfig} [config]
86
+ * @returns {string}
87
+ */
88
+ export function resolvePreferredPhpFpmUnit(config) {
89
+ return preferredPhpFpmUnitName(resolvePhpVersion(config));
90
+ }
91
+
92
+ /**
93
+ * @param {string} phpVersion
94
+ * @param {string[]} [foundUnits]
95
+ * @returns {string}
96
+ */
97
+ export function formatPhpFpmMissingError(phpVersion, foundUnits = []) {
98
+ const preferred = preferredPhpFpmUnitName(phpVersion);
99
+ const found =
100
+ foundUnits.length > 0 ? ` Found unit(s): ${foundUnits.join(', ')}.` : '';
101
+ return (
102
+ `No usable php-fpm systemd service for PHP ${phpVersion} ` +
103
+ `(looked for ${preferred} then php-fpm).${found} ` +
104
+ `Install PHP-FPM matching the project (Ubuntu/Debian: sudo apt install ${preferred}; ` +
105
+ `Amazon Linux/RHEL: often sudo yum install php-fpm) or set phpVersion / backend.phpVersion ` +
106
+ `in deployhub.config.json to match the server, then re-run.`
107
+ );
108
+ }
109
+
110
+ /**
111
+ * @param {string} phpVersion
112
+ * @param {string} foundUnit
113
+ * @returns {string}
114
+ */
115
+ export function formatPhpFpmVersionMismatchError(phpVersion, foundUnit) {
116
+ const preferred = preferredPhpFpmUnitName(phpVersion);
117
+ return (
118
+ `Server has ${foundUnit} active/installed, but this project expects PHP ${phpVersion} ` +
119
+ `(service ${preferred}, or generic php-fpm on RHEL/Amazon Linux). ` +
120
+ `Install ${preferred} / matching PHP ${phpVersion}, or set phpVersion in deployhub.config.json ` +
121
+ `to match the server before deploy.`
122
+ );
123
+ }
124
+
125
+ /**
126
+ * Parse `php -v` / `PHP 8.4.1 (cli)...` style output to major.minor.
127
+ * @param {string} phpVOutput
128
+ * @returns {string|null} e.g. "8.4"
129
+ */
130
+ export function parsePhpMajorMinor(phpVOutput) {
131
+ if (!phpVOutput || typeof phpVOutput !== 'string') return null;
132
+ const m = phpVOutput.match(/\bPHP\s+(\d+)\.(\d+)/i);
133
+ if (!m) return null;
134
+ return `${m[1]}.${m[2]}`;
135
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Shared PHP runtime version for CI (setup-php) and Docker base images.
3
+ * Keep Dockerfile FROM tags and GitHub Actions php-version in lockstep.
4
+ */
5
+
6
+ /** Default PHP for CI / Docker when config does not set phpVersion. */
7
+ export const DEFAULT_PHP_VERSION = '8.4';
8
+
9
+ /**
10
+ * Resolve PHP version for setup-php and `php:{version}-cli-alpine` images.
11
+ * Order: `backend.phpVersion` → top-level `phpVersion` → {@link DEFAULT_PHP_VERSION} (`8.4`).
12
+ * Set either config key in deployhub.config.json to pin a different runtime
13
+ * (e.g. `"phpVersion": "8.3"` or `"backend": { "phpVersion": "8.3" }`).
14
+ *
15
+ * @param {import('../core/config.js').DeployHubConfig} [config]
16
+ * @returns {string}
17
+ */
18
+ export function resolvePhpVersion(config) {
19
+ const fromBackend = config?.backend?.phpVersion;
20
+ if (typeof fromBackend === 'string' && fromBackend.trim()) {
21
+ return fromBackend.trim();
22
+ }
23
+ const fromRoot = config?.phpVersion;
24
+ if (typeof fromRoot === 'string' && fromRoot.trim()) {
25
+ return fromRoot.trim();
26
+ }
27
+ return DEFAULT_PHP_VERSION;
28
+ }