@akash-chowdhury-24/deployhub 2.0.30 → 2.0.32

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
@@ -855,7 +855,7 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
855
855
  1. Set `DOCKER_IMAGE_NAME` in `.env` (e.g. `myuser/myapp` for Docker Hub)
856
856
  2. For private registries (or any push): set `DOCKER_REGISTRY_USERNAME` and `DOCKER_REGISTRY_TOKEN`
857
857
  3. Leave `DOCKER_IMAGE_TAG` unset for a unique tag each build — set it only if you intentionally want a fixed tag
858
- 4. For remote Docker: set `DOCKER_HOST` (e.g. `ssh://ubuntu@203.0.113.10`)
858
+ 4. For a remote Linux host, choose **Remote Linux server via SSH** at init (`remote.mode: "ssh"`) and set `SSH_HOST`, `SSH_USER`, `SSH_KEY_PATH` — DeployHub runs `docker pull`/`run` over node-ssh. Use `DOCKER_HOST` only for the advanced raw CLI transport (`tcp://` or a custom `ssh://` setup you already manage)
859
859
  5. Run `deployhub doctor`, then `git push origin main`
860
860
 
861
861
  | Variable | Description | Example | Where to get it |
@@ -865,7 +865,8 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
865
865
  | `DOCKER_REGISTRY_URL` | Registry URL (optional) | `https://ghcr.io` | Registry docs |
866
866
  | `DOCKER_REGISTRY_USERNAME` | Registry user | `myuser` | Registry account |
867
867
  | `DOCKER_REGISTRY_TOKEN` | Registry password/token | *(secret)* | Docker Hub / GHCR PAT |
868
- | `DOCKER_HOST` | Remote daemon (optional) | `ssh://ubuntu@host` | Remote Docker setup |
868
+ | `DOCKER_HOST` | Advanced raw daemon URI (optional) | `tcp://host:2376` | Only if you manage TLS/ssh:// yourself |
869
+ | `SSH_HOST` / `SSH_USER` / `SSH_KEY_PATH` | Remote Linux via SSH (`remote.mode: ssh`) | `203.0.113.10` / `ubuntu` / `~/.ssh/key.pem` | Same names as EC2 |
869
870
 
870
871
  ### AWS EC2
871
872
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akash-chowdhury-24/deployhub",
3
- "version": "2.0.30",
3
+ "version": "2.0.32",
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 };
@@ -31,6 +31,15 @@ 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
33
  import { resolvePhpVersion } from '../utils/php-version.js';
34
+ import {
35
+ resolveDockerSshTarget,
36
+ probeRemoteDockerPs,
37
+ formatRemoteDockerSshFailure,
38
+ formatRemoteDockerNotInstalled,
39
+ formatRemoteDockerPermissionDenied,
40
+ formatRemoteDockerDaemonOk,
41
+ } from '../utils/docker-remote.js';
42
+ import { resolveDockerRemoteMode } from '../utils/docker-remote-mode.js';
34
43
  import {
35
44
  buildPhpFpmUnitListCommand,
36
45
  formatPhpFpmMissingError,
@@ -183,14 +192,16 @@ export async function runDeploymentChecks(config, envName, envConfig) {
183
192
 
184
193
  /** @type {CheckResult[]} */
185
194
  const checks = [];
186
- const requiredKeys = getDeploymentEnvKeys(deployType, config);
195
+ const requiredKeys = getDeploymentEnvKeys(deployType, config, settings);
187
196
 
188
197
  checks.push(
189
198
  await runCheck(`${deployType} env vars`, async () => {
190
199
  const missing = requiredKeys.filter((k) => {
191
200
  if (k === 'SSH_KEY_PATH') {
192
- return !process.env.SSH_KEY_PATH && !process.env.SSH_KEY;
201
+ return !process.env.SSH_KEY_PATH && !process.env.SSH_KEY && !settings.keyPath;
193
202
  }
203
+ if (k === 'SSH_HOST') return !(settings.host || process.env.SSH_HOST);
204
+ if (k === 'SSH_USER') return !(settings.user || process.env.SSH_USER);
194
205
  return !process.env[k];
195
206
  });
196
207
  if (missing.length > 0) {
@@ -447,22 +458,139 @@ export async function runDeploymentChecks(config, envName, envConfig) {
447
458
  }
448
459
 
449
460
  if (deployType === 'docker') {
450
- checks.push(
451
- await runCheck('Docker daemon', async () => {
452
- try {
453
- const provider = getDeploymentProvider('docker', config, envName);
454
- await provider.testConnection();
455
- return { name: 'Docker daemon', pass: true, message: 'Docker daemon reachable' };
456
- } catch (err) {
457
- const msg = err instanceof Error ? err.message : String(err);
461
+ const dockerRemoteMode = resolveDockerRemoteMode(settings, process.env);
462
+
463
+ if (dockerRemoteMode === 'ssh') {
464
+ const target = resolveDockerSshTarget(settings, process.env);
465
+ const host = target.host;
466
+ const user = target.user;
467
+ const keyPath = target.keyPath;
468
+ const sshPort = target.sshPort;
469
+
470
+ checks.push(
471
+ await runCheck('SSH key', async () => {
472
+ const result = await validateSshKeyForDoctor(
473
+ keyPath ? String(keyPath) : undefined,
474
+ process.env.SSH_KEY
475
+ );
476
+ return {
477
+ name: 'SSH key',
478
+ pass: result.ok,
479
+ message: result.message,
480
+ };
481
+ })
482
+ );
483
+
484
+ checks.push(
485
+ await runCheck('SSH host reachability', async () => {
486
+ if (!host) {
487
+ return {
488
+ name: 'SSH host reachability',
489
+ pass: false,
490
+ message: 'SSH_HOST is required — set it in .env to your server IP or hostname.',
491
+ };
492
+ }
493
+ const result = await testSshHostReachability(String(host), sshPort);
494
+ return {
495
+ name: 'SSH host reachability',
496
+ pass: result.ok,
497
+ message: result.message,
498
+ };
499
+ })
500
+ );
501
+
502
+ checks.push(
503
+ await runCheck('Remote Docker daemon reachable', async () => {
504
+ const probe = await probeRemoteDockerPs(target);
505
+ if (!probe.sshOk) {
506
+ return {
507
+ name: 'Remote Docker daemon reachable',
508
+ pass: false,
509
+ message: formatRemoteDockerSshFailure(host, user),
510
+ };
511
+ }
512
+ if (probe.kind === 'not-installed') {
513
+ return {
514
+ name: 'Remote Docker daemon reachable',
515
+ pass: false,
516
+ message: formatRemoteDockerNotInstalled(host, user),
517
+ };
518
+ }
519
+ if (probe.kind === 'ok' || probe.kind === 'permission') {
520
+ return {
521
+ name: 'Remote Docker daemon reachable',
522
+ pass: true,
523
+ message: formatRemoteDockerDaemonOk(host, user),
524
+ };
525
+ }
458
526
  return {
459
- name: 'Docker daemon',
527
+ name: 'Remote Docker daemon reachable',
460
528
  pass: false,
461
- message: `Docker not reachable — ${msg}. Install Docker or set DOCKER_HOST for a remote daemon.`,
529
+ message:
530
+ probe.detail ||
531
+ `Remote Docker daemon is not reachable (${user}@${host}).`,
462
532
  };
463
- }
464
- })
465
- );
533
+ })
534
+ );
535
+
536
+ checks.push(
537
+ await runCheck('Remote Docker permission', async () => {
538
+ const probe = await probeRemoteDockerPs(target);
539
+ if (!probe.sshOk) {
540
+ return {
541
+ name: 'Remote Docker permission',
542
+ pass: false,
543
+ message: formatRemoteDockerSshFailure(host, user),
544
+ };
545
+ }
546
+ if (probe.kind === 'permission') {
547
+ return {
548
+ name: 'Remote Docker permission',
549
+ pass: false,
550
+ message: formatRemoteDockerPermissionDenied(host, user),
551
+ };
552
+ }
553
+ if (probe.kind === 'ok') {
554
+ return {
555
+ name: 'Remote Docker permission',
556
+ pass: true,
557
+ message: `SSH user '${user}' can access the Docker daemon on ${host}`,
558
+ };
559
+ }
560
+ if (probe.kind === 'not-installed') {
561
+ return {
562
+ name: 'Remote Docker permission',
563
+ pass: false,
564
+ message: formatRemoteDockerNotInstalled(host, user),
565
+ };
566
+ }
567
+ return {
568
+ name: 'Remote Docker permission',
569
+ pass: false,
570
+ message:
571
+ probe.detail ||
572
+ `Cannot verify Docker permissions for '${user}' on ${host}.`,
573
+ };
574
+ })
575
+ );
576
+ } else {
577
+ checks.push(
578
+ await runCheck('Docker daemon', async () => {
579
+ try {
580
+ const provider = getDeploymentProvider('docker', config, envName);
581
+ await provider.testConnection();
582
+ return { name: 'Docker daemon', pass: true, message: 'Docker daemon reachable' };
583
+ } catch (err) {
584
+ const msg = err instanceof Error ? err.message : String(err);
585
+ return {
586
+ name: 'Docker daemon',
587
+ pass: false,
588
+ message: `Docker not reachable — ${msg}. Install Docker or set DOCKER_HOST for a remote daemon.`,
589
+ };
590
+ }
591
+ })
592
+ );
593
+ }
466
594
  }
467
595
 
468
596
  if (deployType === 'kubernetes') {
@@ -644,7 +772,6 @@ async function runBackendProcessChecks(config, envName, deployType = 'ssh') {
644
772
 
645
773
  checks.push(
646
774
  await runCheck('pip', async () => {
647
- // Deploy runs `pip install -r requirements.txt`; accept pip3 or pip.
648
775
  const result = await provider.runRemoteCheck(
649
776
  'command -v pip3 >/dev/null 2>&1 || command -v pip >/dev/null 2>&1'
650
777
  );
@@ -661,24 +788,11 @@ async function runBackendProcessChecks(config, envName, deployType = 'ssh') {
661
788
  })
662
789
  );
663
790
 
664
- checks.push(
665
- await runCheck('gunicorn', async () => {
666
- const result = await provider.runRemoteCheck('which gunicorn || gunicorn --version');
667
- if (result.pass) {
668
- return { name: 'gunicorn', pass: true, message: 'gunicorn available' };
669
- }
670
- return {
671
- name: 'gunicorn',
672
- pass: false,
673
- message: 'not found — run: pip install gunicorn',
674
- };
675
- })
676
- );
677
-
791
+ // FastAPI SSH starts uvicorn, not gunicorn. Requiring gunicorn here is a false failure.
678
792
  if (framework === 'fastapi') {
679
793
  checks.push(
680
794
  await runCheck('uvicorn', async () => {
681
- const result = await provider.runRemoteCheck('which uvicorn || uvicorn --version');
795
+ const result = await provider.runRemoteCheck('command -v uvicorn >/dev/null 2>&1 || uvicorn --version');
682
796
  if (result.pass) {
683
797
  return { name: 'uvicorn', pass: true, message: 'uvicorn available' };
684
798
  }
@@ -689,6 +803,20 @@ async function runBackendProcessChecks(config, envName, deployType = 'ssh') {
689
803
  };
690
804
  })
691
805
  );
806
+ } else {
807
+ checks.push(
808
+ await runCheck('gunicorn', async () => {
809
+ const result = await provider.runRemoteCheck('command -v gunicorn >/dev/null 2>&1 || gunicorn --version');
810
+ if (result.pass) {
811
+ return { name: 'gunicorn', pass: true, message: 'gunicorn available' };
812
+ }
813
+ return {
814
+ name: 'gunicorn',
815
+ pass: false,
816
+ message: 'not found — run: pip install gunicorn',
817
+ };
818
+ })
819
+ );
692
820
  }
693
821
  }
694
822
 
@@ -767,7 +895,7 @@ async function runBackendProcessChecks(config, envName, deployType = 'ssh') {
767
895
  }
768
896
 
769
897
  const active = await provider.runRemoteCheck(
770
- `systemctl is-active ${pick.unit}`
898
+ `sudo -n systemctl is-active ${pick.unit}`
771
899
  );
772
900
  if (active.pass && String(active.message).includes('active')) {
773
901
  const note =
@@ -793,11 +921,28 @@ async function runBackendProcessChecks(config, envName, deployType = 'ssh') {
793
921
 
794
922
  checks.push(
795
923
  await runCheck('nginx', async () => {
796
- const result = await provider.runRemoteCheck('systemctl is-active nginx');
924
+ const installed = await provider.runRemoteCheck(
925
+ 'command -v nginx >/dev/null 2>&1 && echo yes'
926
+ );
927
+ if (!installed.pass || !String(installed.message).includes('yes')) {
928
+ return {
929
+ name: 'nginx',
930
+ pass: false,
931
+ message:
932
+ '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',
933
+ };
934
+ }
935
+ const result = await provider.runRemoteCheck('sudo -n systemctl is-active nginx');
797
936
  if (result.pass && result.message.includes('active')) {
798
937
  return { name: 'nginx', pass: true, message: 'nginx running' };
799
938
  }
800
- return { name: 'nginx', pass: false, message: 'nginx not running' };
939
+ return {
940
+ name: 'nginx',
941
+ pass: false,
942
+ message:
943
+ `nginx is installed but not active (systemctl is-active: ${result.message}). ` +
944
+ 'Start it with: sudo systemctl enable --now nginx',
945
+ };
801
946
  })
802
947
  );
803
948
  }
@@ -823,6 +968,53 @@ async function runBackendProcessChecks(config, envName, deployType = 'ssh') {
823
968
  );
824
969
  }
825
970
 
971
+ if (framework === 'dotnet') {
972
+ checks.push(
973
+ await runCheck('.NET runtime', async () => {
974
+ const result = await provider.runRemoteCheck('dotnet --version');
975
+ if (result.pass) {
976
+ return { name: '.NET runtime', pass: true, message: `dotnet ${result.message.split('\n')[0] || 'found'}` };
977
+ }
978
+ return {
979
+ name: '.NET runtime',
980
+ pass: false,
981
+ message:
982
+ 'dotnet not found on PATH — install the ASP.NET Core runtime on the server ' +
983
+ '(SSH deploy runs `dotnet <dll>` after extract)',
984
+ };
985
+ })
986
+ );
987
+ }
988
+
989
+ if (framework === 'rails' || framework === 'ruby') {
990
+ checks.push(
991
+ await runCheck('Ruby', async () => {
992
+ const result = await provider.runRemoteCheck('command -v ruby >/dev/null 2>&1 && ruby -v');
993
+ if (result.pass) {
994
+ return { name: 'Ruby', pass: true, message: result.message.split('\n')[0] || 'ruby found' };
995
+ }
996
+ return {
997
+ name: 'Ruby',
998
+ pass: false,
999
+ message: 'ruby not found on PATH — install Ruby 3.2+ on the server (SSH deploy runs bundle install)',
1000
+ };
1001
+ })
1002
+ );
1003
+ checks.push(
1004
+ await runCheck('Bundler', async () => {
1005
+ const result = await provider.runRemoteCheck('command -v bundle');
1006
+ if (result.pass) {
1007
+ return { name: 'Bundler', pass: true, message: 'bundle found on PATH' };
1008
+ }
1009
+ return {
1010
+ name: 'Bundler',
1011
+ pass: false,
1012
+ message: 'bundle not found on PATH — run: gem install bundler',
1013
+ };
1014
+ })
1015
+ );
1016
+ }
1017
+
826
1018
  return checks;
827
1019
  }
828
1020
 
@@ -1078,7 +1270,9 @@ export function registerDoctorCommand(program) {
1078
1270
  const method = getEnvMethod(env);
1079
1271
  if (!method) continue;
1080
1272
  // Local/.env uses unprefixed names; prefixed CI names are checked separately below.
1081
- required.push(...getDeploymentSecretKeys(method, config));
1273
+ required.push(
1274
+ ...getDeploymentSecretKeys(method, config, getEnvSettings(env))
1275
+ );
1082
1276
  }
1083
1277
 
1084
1278
  const unique = [...new Set(required)];
@@ -1115,7 +1309,7 @@ export function registerDoctorCommand(program) {
1115
1309
  config,
1116
1310
  config.environments
1117
1311
  );
1118
- const baseKeys = getDeploymentSecretKeys(method, config);
1312
+ const baseKeys = getDeploymentSecretKeys(method, config, getEnvSettings(env));
1119
1313
 
1120
1314
  for (let i = 0; i < baseKeys.length; i++) {
1121
1315
  const base = baseKeys[i];
@@ -54,6 +54,16 @@ const MethodConfigSchema = z
54
54
  dockerImageName: z.string().optional(),
55
55
  dockerRegistryUrl: z.string().optional(),
56
56
  dockerHost: z.string().optional(),
57
+ /**
58
+ * Docker-method only. Kubernetes must ignore this — it deploys via kubectl,
59
+ * never a remote Docker daemon. `ssh` = node-ssh + remote docker CLI;
60
+ * `local` = this machine; `raw` = unmanaged DOCKER_HOST (ssh:// or tcp://).
61
+ */
62
+ remote: z
63
+ .object({
64
+ mode: z.enum(['ssh', 'local', 'raw']),
65
+ })
66
+ .optional(),
57
67
  healthCheckUrl: z.string().optional(),
58
68
  appName: z.string().optional(),
59
69
  framework: z.string().optional(),