@akash-chowdhury-24/deployhub 2.0.30 → 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/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.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 };
@@ -644,7 +644,6 @@ async function runBackendProcessChecks(config, envName, deployType = 'ssh') {
644
644
 
645
645
  checks.push(
646
646
  await runCheck('pip', async () => {
647
- // Deploy runs `pip install -r requirements.txt`; accept pip3 or pip.
648
647
  const result = await provider.runRemoteCheck(
649
648
  'command -v pip3 >/dev/null 2>&1 || command -v pip >/dev/null 2>&1'
650
649
  );
@@ -661,24 +660,11 @@ async function runBackendProcessChecks(config, envName, deployType = 'ssh') {
661
660
  })
662
661
  );
663
662
 
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
-
663
+ // FastAPI SSH starts uvicorn, not gunicorn. Requiring gunicorn here is a false failure.
678
664
  if (framework === 'fastapi') {
679
665
  checks.push(
680
666
  await runCheck('uvicorn', async () => {
681
- const result = await provider.runRemoteCheck('which uvicorn || uvicorn --version');
667
+ const result = await provider.runRemoteCheck('command -v uvicorn >/dev/null 2>&1 || uvicorn --version');
682
668
  if (result.pass) {
683
669
  return { name: 'uvicorn', pass: true, message: 'uvicorn available' };
684
670
  }
@@ -689,6 +675,20 @@ async function runBackendProcessChecks(config, envName, deployType = 'ssh') {
689
675
  };
690
676
  })
691
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
+ );
692
692
  }
693
693
  }
694
694
 
@@ -767,7 +767,7 @@ async function runBackendProcessChecks(config, envName, deployType = 'ssh') {
767
767
  }
768
768
 
769
769
  const active = await provider.runRemoteCheck(
770
- `systemctl is-active ${pick.unit}`
770
+ `sudo -n systemctl is-active ${pick.unit}`
771
771
  );
772
772
  if (active.pass && String(active.message).includes('active')) {
773
773
  const note =
@@ -793,11 +793,28 @@ async function runBackendProcessChecks(config, envName, deployType = 'ssh') {
793
793
 
794
794
  checks.push(
795
795
  await runCheck('nginx', async () => {
796
- 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');
797
808
  if (result.pass && result.message.includes('active')) {
798
809
  return { name: 'nginx', pass: true, message: 'nginx running' };
799
810
  }
800
- 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
+ };
801
818
  })
802
819
  );
803
820
  }
@@ -823,6 +840,53 @@ async function runBackendProcessChecks(config, envName, deployType = 'ssh') {
823
840
  );
824
841
  }
825
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
+
826
890
  return checks;
827
891
  }
828
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}`,
@@ -441,8 +441,13 @@ export function createSshProvider(config, envName, env = process.env) {
441
441
 
442
442
  if (framework === 'dotnet') {
443
443
  await stopScopedBackendProcess(ssh, targetPath);
444
- const dll = startCommand?.replace('dotnet ', '') || 'App.dll';
445
- 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
+ );
446
451
  return;
447
452
  }
448
453
 
@@ -576,13 +581,35 @@ export function createSshProvider(config, envName, env = process.env) {
576
581
  log.success('Nginx config tested and reloaded');
577
582
  }
578
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
+
579
606
  /**
580
607
  * @param {import('node-ssh').NodeSSH} ssh
581
608
  * @param {string} remoteZip
582
609
  * @param {string} targetPath
583
610
  */
584
611
  async function extractToPath(ssh, remoteZip, targetPath) {
585
- await exec(ssh, `mkdir -p ${sh(targetPath)}`);
612
+ await ensureWritableDeployDir(ssh, targetPath);
586
613
  await exec(ssh, `unzip -o ${sh(remoteZip)} -d ${sh(targetPath)}`);
587
614
  }
588
615
 
@@ -606,13 +633,13 @@ export function createSshProvider(config, envName, env = process.env) {
606
633
  await exec(ssh, `mkdir -p ${sh(remoteStaging)}`);
607
634
  await exec(ssh, `unzip -o ${sh(remoteZip)} -d ${sh(remoteStaging)}`);
608
635
 
609
- await exec(ssh, `mkdir -p ${sh(frontendDeployPath)}`);
636
+ await ensureWritableDeployDir(ssh, frontendDeployPath);
610
637
  await exec(
611
638
  ssh,
612
639
  `rsync -a ${sh(remoteStaging)}/ ${sh(frontendDeployPath)}/ --exclude backend || cp -r ${sh(remoteStaging)}/* ${sh(frontendDeployPath)}/`
613
640
  );
614
641
 
615
- await exec(ssh, `mkdir -p ${sh(backendDeployPath)}`);
642
+ await ensureWritableDeployDir(ssh, backendDeployPath);
616
643
  await exec(
617
644
  ssh,
618
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',
@@ -76,6 +76,8 @@ function defaultBuildOutput(framework, projectType) {
76
76
  * @param {string} projectType
77
77
  */
78
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;
79
81
  if (
80
82
  projectType === 'frontend' ||
81
83
  ['react', 'vue', 'angular', 'svelte', 'astro', 'vanilla'].includes(framework)
@@ -244,9 +246,21 @@ export function generateDockerfile(config) {
244
246
  * @param {{ buildCommand: string|null, buildOutput: string, port: number }} settings
245
247
  */
246
248
  function generateFrontendStaticDockerfile(settings) {
247
- const buildCmd = settings.buildCommand || 'npm run build';
249
+ const buildCmd = settings.buildCommand;
248
250
  const output = settings.buildOutput || 'dist';
249
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
+
250
264
  return `${GENERATED_HEADER}
251
265
  FROM node:20-alpine AS build
252
266
  WORKDIR /app
@@ -281,6 +295,7 @@ FROM node:20-alpine AS build
281
295
  WORKDIR /app
282
296
  COPY --from=deps /app/node_modules ./node_modules
283
297
  COPY . .
298
+ RUN mkdir -p public
284
299
  RUN ${buildCmd}
285
300
 
286
301
  FROM node:20-alpine AS runner
@@ -453,9 +468,8 @@ function generatePythonDockerfile(settings) {
453
468
  return `${GENERATED_HEADER}
454
469
  FROM python:3.11-slim
455
470
  WORKDIR /app
456
- COPY requirements.txt* ./
457
- RUN if [ -f requirements.txt ]; then pip install --no-cache-dir -r requirements.txt; fi
458
471
  COPY . .
472
+ RUN if [ -f requirements.txt ]; then pip install --no-cache-dir -r requirements.txt; fi
459
473
  EXPOSE ${port}
460
474
  CMD ${JSON.stringify(startParts)}
461
475
  `;
@@ -485,7 +499,7 @@ function generateLaravelDockerfile(settings) {
485
499
  return `${GENERATED_HEADER}
486
500
  FROM composer:2 AS vendor
487
501
  WORKDIR /app
488
- COPY composer.json composer.lock* ./
502
+ COPY composer.json ./
489
503
  RUN composer install --no-dev --optimize-autoloader --no-interaction --ignore-platform-reqs --no-scripts
490
504
 
491
505
  FROM php:${phpVersion}-cli-alpine
@@ -519,7 +533,7 @@ function generateSymfonyDockerfile(settings) {
519
533
  return `${GENERATED_HEADER}
520
534
  FROM composer:2 AS vendor
521
535
  WORKDIR /app
522
- COPY composer.json composer.lock* ./
536
+ COPY composer.json ./
523
537
  RUN composer install --no-dev --optimize-autoloader --no-interaction --ignore-platform-reqs --no-scripts
524
538
 
525
539
  FROM php:${phpVersion}-cli-alpine
@@ -600,7 +614,7 @@ function generateGoDockerfile(settings) {
600
614
  return `${GENERATED_HEADER}
601
615
  FROM golang:1.22-alpine AS build
602
616
  WORKDIR /app
603
- COPY go.mod go.sum* ./
617
+ COPY go.mod ./
604
618
  RUN go mod download
605
619
  COPY . .
606
620
  RUN mkdir -p /app/bin && ${buildCmd}
@@ -622,6 +636,11 @@ function generateDotnetDockerfile(settings) {
622
636
  const buildCmd = settings.buildCommand || 'dotnet publish -c Release -o publish';
623
637
  const port = settings.port || 5000;
624
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
+ ];
625
644
 
626
645
  return `${GENERATED_HEADER}
627
646
  FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
@@ -636,7 +655,7 @@ WORKDIR /app
636
655
  COPY --from=build /src/${output} .
637
656
  EXPOSE ${port}
638
657
  ENV ASPNETCORE_URLS=http://+:${port}
639
- CMD ["dotnet", "App.dll"]
658
+ CMD ${JSON.stringify(startParts)}
640
659
  `;
641
660
  }
642
661
 
@@ -654,7 +673,7 @@ function generateRailsDockerfile(settings) {
654
673
  FROM ruby:3.2-slim AS build
655
674
  WORKDIR /app
656
675
  RUN apt-get update -qq && apt-get install -y build-essential libpq-dev && rm -rf /var/lib/apt/lists/*
657
- COPY Gemfile Gemfile.lock* ./
676
+ COPY Gemfile ./
658
677
  RUN bundle config set --local without 'development test' && bundle install
659
678
  COPY . .
660
679
  RUN bundle exec rake assets:precompile || true
@@ -683,7 +702,7 @@ function generateRubyDockerfile(settings) {
683
702
  FROM ruby:3.2-slim
684
703
  WORKDIR /app
685
704
  RUN apt-get update -qq && apt-get install -y build-essential && rm -rf /var/lib/apt/lists/*
686
- COPY Gemfile Gemfile.lock* ./
705
+ COPY Gemfile ./
687
706
  RUN bundle config set --local without 'development test' && bundle install
688
707
  COPY . .
689
708
  EXPOSE ${port}
@@ -863,12 +863,10 @@ ${rollbackRun}
863
863
  * @param {import('../core/config.js').DeployHubConfig} [config]
864
864
  * @returns {string}
865
865
  */
866
- function getInstallDepsCommand(config) {
867
- if (!config) return 'npm install';
868
-
866
+ function getBackendInstallDepsCommand(config) {
869
867
  const framework =
870
- config.backend?.framework || config.framework || 'express';
871
- const language = config.backend?.language || config.language;
868
+ config?.backend?.framework || config?.framework || 'express';
869
+ const language = config?.backend?.language || config?.language;
872
870
 
873
871
  if (language === 'python' || ['fastapi', 'django', 'flask', 'python'].includes(framework)) {
874
872
  return 'pip install -r requirements.txt';
@@ -891,6 +889,25 @@ function getInstallDepsCommand(config) {
891
889
  return 'npm install';
892
890
  }
893
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
+
894
911
  /**
895
912
  * Write deployhub.yml and deployhub-rollback.yml from the same secret/env helpers.
896
913
  *
@@ -20,8 +20,10 @@ export function preferredPhpFpmUnitName(phpVersion) {
20
20
  * @returns {string}
21
21
  */
22
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.
23
25
  return (
24
- `systemctl list-unit-files --type=service --no-legend ` +
26
+ `sudo -n systemctl list-unit-files --type=service --no-legend ` +
25
27
  `'php*-fpm.service' 'php-fpm.service' 2>/dev/null ` +
26
28
  `| awk '{print $1}' | sed 's/\\.service$//' | sort -u`
27
29
  );