@akash-chowdhury-24/deployhub 2.0.28 → 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.28",
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([
@@ -471,20 +477,27 @@ function generateLaravelDockerfile(settings) {
471
477
  `--port=${port}`,
472
478
  ]);
473
479
 
480
+ // Vendor stage copies only composer.json/lock for layer caching. Laravel's
481
+ // post-autoload-dump runs `php artisan package:discover`, which needs the
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).
474
485
  return `${GENERATED_HEADER}
475
486
  FROM composer:2 AS vendor
476
487
  WORKDIR /app
477
488
  COPY composer.json composer.lock* ./
478
- RUN composer install --no-dev --optimize-autoloader --no-interaction --ignore-platform-reqs
489
+ RUN composer install --no-dev --optimize-autoloader --no-interaction --ignore-platform-reqs --no-scripts
479
490
 
480
- FROM php:8.2-cli-alpine
491
+ FROM php:${phpVersion}-cli-alpine
481
492
  WORKDIR /var/www/html
493
+ COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
482
494
  RUN apk add --no-cache icu-libs libzip \\
483
495
  && apk add --no-cache --virtual .build-deps $PHPIZE_DEPS icu-dev libzip-dev oniguruma-dev \\
484
496
  && docker-php-ext-install -j$(nproc) pdo_mysql mbstring zip opcache \\
485
497
  && apk del .build-deps
486
498
  COPY --from=vendor /app/vendor ./vendor
487
499
  COPY . .
500
+ RUN composer dump-autoload --optimize --no-dev --no-interaction
488
501
  RUN chown -R www-data:www-data storage bootstrap/cache || true
489
502
  EXPOSE ${port}
490
503
  CMD ${cmd}
@@ -492,28 +505,33 @@ CMD ${cmd}
492
505
  }
493
506
 
494
507
  /**
495
- * @param {{ port: number }} settings
508
+ * @param {{ port: number, phpVersion?: string }} settings
496
509
  */
497
510
  function generateSymfonyDockerfile(settings) {
498
511
  const port = settings.port || 80;
512
+ const phpVersion = settings.phpVersion || resolvePhpVersion();
499
513
  // Built-in server binds HTTP to EXPOSE — suitable for a single-container pod.
500
514
  // Prefer a production reverse-proxy image if you outgrow this starter template.
501
515
  const cmd = JSON.stringify(['php', '-S', `0.0.0.0:${port}`, '-t', 'public']);
502
516
 
517
+ // Same as Laravel: vendor stage has no app source, so skip Composer scripts
518
+ // (Flex recipes / auto-scripts often need bin/console & the project tree).
503
519
  return `${GENERATED_HEADER}
504
520
  FROM composer:2 AS vendor
505
521
  WORKDIR /app
506
522
  COPY composer.json composer.lock* ./
507
- RUN composer install --no-dev --optimize-autoloader --no-interaction --ignore-platform-reqs
523
+ RUN composer install --no-dev --optimize-autoloader --no-interaction --ignore-platform-reqs --no-scripts
508
524
 
509
- FROM php:8.2-cli-alpine
525
+ FROM php:${phpVersion}-cli-alpine
510
526
  WORKDIR /var/www/html
527
+ COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
511
528
  RUN apk add --no-cache icu-libs libzip \\
512
529
  && apk add --no-cache --virtual .build-deps $PHPIZE_DEPS icu-dev libzip-dev oniguruma-dev \\
513
530
  && docker-php-ext-install -j$(nproc) pdo_mysql mbstring zip opcache \\
514
531
  && apk del .build-deps
515
532
  COPY --from=vendor /app/vendor ./vendor
516
533
  COPY . .
534
+ RUN composer dump-autoload --optimize --no-dev --no-interaction
517
535
  EXPOSE ${port}
518
536
  CMD ${cmd}
519
537
  `;
@@ -522,10 +540,11 @@ CMD ${cmd}
522
540
  /**
523
541
  * Plain PHP (no Laravel/Symfony): single-process built-in server.
524
542
  * Docroot prefers public/ at runtime when present; optional composer install.
525
- * @param {{ port: number }} settings
543
+ * @param {{ port: number, phpVersion?: string }} settings
526
544
  */
527
545
  function generatePhpDockerfile(settings) {
528
546
  const port = settings.port || 80;
547
+ const phpVersion = settings.phpVersion || resolvePhpVersion();
529
548
  const cmd = JSON.stringify([
530
549
  'sh',
531
550
  '-c',
@@ -533,7 +552,7 @@ function generatePhpDockerfile(settings) {
533
552
  ]);
534
553
 
535
554
  return `${GENERATED_HEADER}
536
- FROM php:8.2-cli-alpine
555
+ FROM php:${phpVersion}-cli-alpine
537
556
  WORKDIR /var/www/html
538
557
  COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
539
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
+ }