@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 +1 -1
- package/src/artifact/engine.js +111 -83
- package/src/commands/doctor.js +83 -19
- package/src/deployment/deployment-env.js +1 -1
- package/src/deployment/init-helpers.js +3 -1
- package/src/deployment/providers/ssh.js +32 -5
- package/src/detectors/frontend.detector.js +1 -1
- package/src/utils/docker-image.js +3 -3
- package/src/utils/dockerfile.js +28 -9
- package/src/utils/github-actions.js +22 -5
- package/src/utils/php-fpm.js +3 -1
package/package.json
CHANGED
package/src/artifact/engine.js
CHANGED
|
@@ -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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
}
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
284
|
-
|
|
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(
|
|
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
|
|
459
|
+
await removeStagingDir(stagingDir);
|
|
432
460
|
log.success(`Artifact created at ${artifactDir}`);
|
|
433
461
|
|
|
434
462
|
return { artifactDir, zipPath };
|
package/src/commands/doctor.js
CHANGED
|
@@ -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
|
-
|
|
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('
|
|
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
|
|
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 {
|
|
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
|
-
'
|
|
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
|
-
|
|
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
|
-
|
|
445
|
-
|
|
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
|
|
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
|
|
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
|
|
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)}/`
|
|
@@ -174,7 +174,7 @@ WORKDIR /app
|
|
|
174
174
|
COPY ${dir}/ .
|
|
175
175
|
EXPOSE ${port}
|
|
176
176
|
ENV ASPNETCORE_URLS=http://+:${port}
|
|
177
|
-
CMD ["
|
|
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',
|
package/src/utils/dockerfile.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
867
|
-
if (!config) return 'npm install';
|
|
868
|
-
|
|
866
|
+
function getBackendInstallDepsCommand(config) {
|
|
869
867
|
const framework =
|
|
870
|
-
config
|
|
871
|
-
const language = config
|
|
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
|
*
|
package/src/utils/php-fpm.js
CHANGED
|
@@ -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
|
);
|