@akash-chowdhury-24/deployhub 2.0.29 → 2.0.30

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.30",
4
4
  "description": "Zero-configuration deployment and artifact manager",
5
5
  "type": "module",
6
6
  "main": "./src/cli/index.js",
@@ -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
@@ -683,15 +693,101 @@ async function runBackendProcessChecks(config, envName, deployType = 'ssh') {
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
+ `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
 
@@ -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
 
@@ -441,6 +461,38 @@ export function createSshProvider(config, envName, env = process.env) {
441
461
  await exec(ssh, 'pm2 save');
442
462
  }
443
463
 
464
+ /**
465
+ * Resolve the php-fpm systemd unit on the remote host.
466
+ * Prefers php{version}-fpm (Debian/Ubuntu), then php-fpm (RHEL/Amazon Linux).
467
+ * Throws if nothing usable is installed — never restarts a guessed missing unit.
468
+ *
469
+ * @param {import('node-ssh').NodeSSH} ssh
470
+ * @param {string} phpVersion
471
+ * @returns {Promise<string>}
472
+ */
473
+ async function resolveRemotePhpFpmUnit(ssh, phpVersion) {
474
+ const listCmd = buildPhpFpmUnitListCommand();
475
+ const listed = await ssh.execCommand(listCmd);
476
+ const units = parsePhpFpmUnitList(listed.stdout || '');
477
+ const pick = pickPhpFpmUnitName(units, phpVersion);
478
+
479
+ if (pick?.match === 'exact' || pick?.match === 'generic') {
480
+ if (pick.match === 'generic') {
481
+ log.info(
482
+ `Preferred ${preferredPhpFpmUnitName(phpVersion)} not installed; ` +
483
+ `using generic php-fpm (typical on Amazon Linux/RHEL)`
484
+ );
485
+ }
486
+ return pick.unit;
487
+ }
488
+
489
+ if (pick?.match === 'other-version') {
490
+ throw new Error(formatPhpFpmVersionMismatchError(phpVersion, pick.unit));
491
+ }
492
+
493
+ throw new Error(formatPhpFpmMissingError(phpVersion, units));
494
+ }
495
+
444
496
  /**
445
497
  * @param {import('node-ssh').NodeSSH} ssh
446
498
  * @param {string} remotePath
@@ -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
 
@@ -457,10 +462,11 @@ CMD ${JSON.stringify(startParts)}
457
462
  }
458
463
 
459
464
  /**
460
- * @param {{ port: number }} settings
465
+ * @param {{ port: number, phpVersion?: string }} settings
461
466
  */
462
467
  function generateLaravelDockerfile(settings) {
463
468
  const port = settings.port || 80;
469
+ const phpVersion = settings.phpVersion || resolvePhpVersion();
464
470
  // Single-process HTTP for container/K8s fitness. SSH deploys still use host
465
471
  // php-fpm + nginx; this image must listen on EXPOSE (FPM alone listens on 9000).
466
472
  const cmd = JSON.stringify([
@@ -474,13 +480,15 @@ function generateLaravelDockerfile(settings) {
474
480
  // Vendor stage copies only composer.json/lock for layer caching. Laravel's
475
481
  // post-autoload-dump runs `php artisan package:discover`, which needs the
476
482
  // full app — so install with --no-scripts here, then dump-autoload after COPY.
483
+ // --ignore-platform-reqs stays: composer:2 may ship a different PHP than the
484
+ // runtime image (phpVersion is applied only to the final stage below).
477
485
  return `${GENERATED_HEADER}
478
486
  FROM composer:2 AS vendor
479
487
  WORKDIR /app
480
488
  COPY composer.json composer.lock* ./
481
489
  RUN composer install --no-dev --optimize-autoloader --no-interaction --ignore-platform-reqs --no-scripts
482
490
 
483
- FROM php:8.2-cli-alpine
491
+ FROM php:${phpVersion}-cli-alpine
484
492
  WORKDIR /var/www/html
485
493
  COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
486
494
  RUN apk add --no-cache icu-libs libzip \\
@@ -497,10 +505,11 @@ CMD ${cmd}
497
505
  }
498
506
 
499
507
  /**
500
- * @param {{ port: number }} settings
508
+ * @param {{ port: number, phpVersion?: string }} settings
501
509
  */
502
510
  function generateSymfonyDockerfile(settings) {
503
511
  const port = settings.port || 80;
512
+ const phpVersion = settings.phpVersion || resolvePhpVersion();
504
513
  // Built-in server binds HTTP to EXPOSE — suitable for a single-container pod.
505
514
  // Prefer a production reverse-proxy image if you outgrow this starter template.
506
515
  const cmd = JSON.stringify(['php', '-S', `0.0.0.0:${port}`, '-t', 'public']);
@@ -513,7 +522,7 @@ WORKDIR /app
513
522
  COPY composer.json composer.lock* ./
514
523
  RUN composer install --no-dev --optimize-autoloader --no-interaction --ignore-platform-reqs --no-scripts
515
524
 
516
- FROM php:8.2-cli-alpine
525
+ FROM php:${phpVersion}-cli-alpine
517
526
  WORKDIR /var/www/html
518
527
  COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
519
528
  RUN apk add --no-cache icu-libs libzip \\
@@ -531,10 +540,11 @@ CMD ${cmd}
531
540
  /**
532
541
  * Plain PHP (no Laravel/Symfony): single-process built-in server.
533
542
  * Docroot prefers public/ at runtime when present; optional composer install.
534
- * @param {{ port: number }} settings
543
+ * @param {{ port: number, phpVersion?: string }} settings
535
544
  */
536
545
  function generatePhpDockerfile(settings) {
537
546
  const port = settings.port || 80;
547
+ const phpVersion = settings.phpVersion || resolvePhpVersion();
538
548
  const cmd = JSON.stringify([
539
549
  'sh',
540
550
  '-c',
@@ -542,7 +552,7 @@ function generatePhpDockerfile(settings) {
542
552
  ]);
543
553
 
544
554
  return `${GENERATED_HEADER}
545
- FROM php:8.2-cli-alpine
555
+ FROM php:${phpVersion}-cli-alpine
546
556
  WORKDIR /var/www/html
547
557
  COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
548
558
  RUN apk add --no-cache icu-libs libzip \\
@@ -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}
@@ -0,0 +1,133 @@
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
+ return (
24
+ `systemctl list-unit-files --type=service --no-legend ` +
25
+ `'php*-fpm.service' 'php-fpm.service' 2>/dev/null ` +
26
+ `| awk '{print $1}' | sed 's/\\.service$//' | sort -u`
27
+ );
28
+ }
29
+
30
+ /**
31
+ * @param {string} stdout
32
+ * @returns {string[]}
33
+ */
34
+ export function parsePhpFpmUnitList(stdout) {
35
+ if (!stdout || typeof stdout !== 'string') return [];
36
+ /** @type {Set<string>} */
37
+ const units = new Set();
38
+ for (const line of stdout.split(/\r?\n/)) {
39
+ const raw = line.trim();
40
+ if (!raw) continue;
41
+ // list-unit-files: "php8.4-fpm.service enabled" — first column only
42
+ const first = raw.split(/\s+/)[0] || '';
43
+ const name = first.replace(/\.service$/i, '');
44
+ if (!name) continue;
45
+ if (name === 'php-fpm' || /^php[\d.]+-fpm$/i.test(name)) {
46
+ units.add(name === 'php-fpm' ? 'php-fpm' : name);
47
+ }
48
+ }
49
+ return [...units];
50
+ }
51
+
52
+
53
+ /**
54
+ * @typedef {{ unit: string, match: 'exact'|'generic'|'other-version' }} PhpFpmUnitPick
55
+ */
56
+
57
+ /**
58
+ * Prefer versioned unit (Debian/Ubuntu), then generic php-fpm (RHEL/AL),
59
+ * else surface another versioned unit as a mismatch (do not silently use it
60
+ * for restart — caller decides whether to error).
61
+ *
62
+ * @param {string[]} availableUnits
63
+ * @param {string} phpVersion
64
+ * @returns {PhpFpmUnitPick | null}
65
+ */
66
+ export function pickPhpFpmUnitName(availableUnits, phpVersion) {
67
+ const preferred = preferredPhpFpmUnitName(phpVersion);
68
+ const normalized = availableUnits.map((u) => u.trim()).filter(Boolean);
69
+ if (normalized.includes(preferred)) {
70
+ return { unit: preferred, match: 'exact' };
71
+ }
72
+ if (normalized.includes('php-fpm')) {
73
+ return { unit: 'php-fpm', match: 'generic' };
74
+ }
75
+ const other = normalized.find((u) => /^php[\d.]+-fpm$/i.test(u));
76
+ if (other) {
77
+ return { unit: other, match: 'other-version' };
78
+ }
79
+ return null;
80
+ }
81
+
82
+ /**
83
+ * @param {import('../core/config.js').DeployHubConfig} [config]
84
+ * @returns {string}
85
+ */
86
+ export function resolvePreferredPhpFpmUnit(config) {
87
+ return preferredPhpFpmUnitName(resolvePhpVersion(config));
88
+ }
89
+
90
+ /**
91
+ * @param {string} phpVersion
92
+ * @param {string[]} [foundUnits]
93
+ * @returns {string}
94
+ */
95
+ export function formatPhpFpmMissingError(phpVersion, foundUnits = []) {
96
+ const preferred = preferredPhpFpmUnitName(phpVersion);
97
+ const found =
98
+ foundUnits.length > 0 ? ` Found unit(s): ${foundUnits.join(', ')}.` : '';
99
+ return (
100
+ `No usable php-fpm systemd service for PHP ${phpVersion} ` +
101
+ `(looked for ${preferred} then php-fpm).${found} ` +
102
+ `Install PHP-FPM matching the project (Ubuntu/Debian: sudo apt install ${preferred}; ` +
103
+ `Amazon Linux/RHEL: often sudo yum install php-fpm) or set phpVersion / backend.phpVersion ` +
104
+ `in deployhub.config.json to match the server, then re-run.`
105
+ );
106
+ }
107
+
108
+ /**
109
+ * @param {string} phpVersion
110
+ * @param {string} foundUnit
111
+ * @returns {string}
112
+ */
113
+ export function formatPhpFpmVersionMismatchError(phpVersion, foundUnit) {
114
+ const preferred = preferredPhpFpmUnitName(phpVersion);
115
+ return (
116
+ `Server has ${foundUnit} active/installed, but this project expects PHP ${phpVersion} ` +
117
+ `(service ${preferred}, or generic php-fpm on RHEL/Amazon Linux). ` +
118
+ `Install ${preferred} / matching PHP ${phpVersion}, or set phpVersion in deployhub.config.json ` +
119
+ `to match the server before deploy.`
120
+ );
121
+ }
122
+
123
+ /**
124
+ * Parse `php -v` / `PHP 8.4.1 (cli)...` style output to major.minor.
125
+ * @param {string} phpVOutput
126
+ * @returns {string|null} e.g. "8.4"
127
+ */
128
+ export function parsePhpMajorMinor(phpVOutput) {
129
+ if (!phpVOutput || typeof phpVOutput !== 'string') return null;
130
+ const m = phpVOutput.match(/\bPHP\s+(\d+)\.(\d+)/i);
131
+ if (!m) return null;
132
+ return `${m[1]}.${m[2]}`;
133
+ }
@@ -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
+ }