@akash-chowdhury-24/deployhub 2.0.21 → 2.0.22
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/adapters/dotnet.adapter.js +7 -2
- package/src/adapters/go.adapter.js +7 -2
- package/src/adapters/java.adapter.js +7 -2
- package/src/adapters/node.adapter.js +3 -3
- package/src/adapters/php.adapter.js +7 -2
- package/src/adapters/python.adapter.js +7 -2
- package/src/adapters/rails.adapter.js +11 -4
- package/src/artifact/engine.js +57 -7
- package/src/cli/fatal-error.js +46 -0
- package/src/cli/index.js +9 -1
- package/src/commands/artifact.js +5 -4
- package/src/commands/build.js +3 -9
- package/src/commands/deploy.js +3 -2
- package/src/commands/doctor.js +41 -25
- package/src/commands/env.js +6 -6
- package/src/commands/rollback.js +3 -2
- package/src/commands/storage.js +2 -8
- package/src/commands/sync-k8s-ports.js +3 -2
- package/src/commands/sync-workflows.js +3 -2
- package/src/commands/verify.js +3 -2
- package/src/core/load-config-or-exit.js +37 -0
- package/src/core/stages.js +2 -0
- package/src/deployment/init-prompts.js +31 -1
- package/src/deployment/providers/ssh.js +48 -3
- package/src/detectors/backend.detector.js +16 -1
- package/src/detectors/php.js +3 -2
- package/src/detectors/python.js +3 -2
- package/src/utils/docker-image-deploy.js +9 -1
- package/src/utils/python-app-target.js +247 -0
package/package.json
CHANGED
|
@@ -23,8 +23,13 @@ function create(config, cwd) {
|
|
|
23
23
|
},
|
|
24
24
|
|
|
25
25
|
async build() {
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
const buildCommand = config.buildCommand;
|
|
27
|
+
if (buildCommand == null || String(buildCommand).trim() === '') {
|
|
28
|
+
log.info('No build step required — skipping');
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
log.info(`Running: ${buildCommand}`);
|
|
32
|
+
const [cmd, ...args] = String(buildCommand).split(' ');
|
|
28
33
|
await execa(cmd, args, { cwd, stdio: 'inherit', shell: true });
|
|
29
34
|
},
|
|
30
35
|
|
|
@@ -22,8 +22,13 @@ function create(config, cwd) {
|
|
|
22
22
|
},
|
|
23
23
|
|
|
24
24
|
async build() {
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
const buildCommand = config.buildCommand;
|
|
26
|
+
if (buildCommand == null || String(buildCommand).trim() === '') {
|
|
27
|
+
log.info('No build step required — skipping');
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
log.info(`Running: ${buildCommand}`);
|
|
31
|
+
const [cmd, ...args] = String(buildCommand).split(' ');
|
|
27
32
|
await execa(cmd, args, { cwd, stdio: 'inherit', shell: true });
|
|
28
33
|
},
|
|
29
34
|
|
|
@@ -28,8 +28,13 @@ function create(config, cwd) {
|
|
|
28
28
|
},
|
|
29
29
|
|
|
30
30
|
async build() {
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
const buildCommand = config.buildCommand;
|
|
32
|
+
if (buildCommand == null || String(buildCommand).trim() === '') {
|
|
33
|
+
log.info('No build step required — skipping');
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
log.info(`Running: ${buildCommand}`);
|
|
37
|
+
const [cmd, ...args] = String(buildCommand).split(' ');
|
|
33
38
|
await execa(cmd, args, { cwd, stdio: 'inherit', shell: true });
|
|
34
39
|
},
|
|
35
40
|
|
|
@@ -50,13 +50,13 @@ function create(config, cwd) {
|
|
|
50
50
|
(config.projectType === 'both' ? config.frontend?.buildCommand : null) ||
|
|
51
51
|
(config.projectType === 'backend' ? config.backend?.buildCommand : null);
|
|
52
52
|
|
|
53
|
-
if (
|
|
54
|
-
log.info('No build
|
|
53
|
+
if (buildCommand == null || String(buildCommand).trim() === '') {
|
|
54
|
+
log.info('No build step required — skipping');
|
|
55
55
|
return;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
log.info(`Running build: ${buildCommand}`);
|
|
59
|
-
const [cmd, ...args] = buildCommand.split(' ');
|
|
59
|
+
const [cmd, ...args] = String(buildCommand).split(' ');
|
|
60
60
|
await execa(cmd, args, { cwd, stdio: 'inherit', shell: true });
|
|
61
61
|
},
|
|
62
62
|
|
|
@@ -25,8 +25,13 @@ function create(config, cwd) {
|
|
|
25
25
|
},
|
|
26
26
|
|
|
27
27
|
async build() {
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
const buildCommand = config.buildCommand;
|
|
29
|
+
if (buildCommand == null || String(buildCommand).trim() === '') {
|
|
30
|
+
log.info('No build step required — skipping');
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
log.info(`Running: ${buildCommand}`);
|
|
34
|
+
const [cmd, ...args] = String(buildCommand).split(' ');
|
|
30
35
|
await execa(cmd, args, { cwd, stdio: 'inherit', shell: true });
|
|
31
36
|
},
|
|
32
37
|
|
|
@@ -34,8 +34,13 @@ function create(config, cwd) {
|
|
|
34
34
|
},
|
|
35
35
|
|
|
36
36
|
async build() {
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
const buildCommand = config.buildCommand;
|
|
38
|
+
if (buildCommand == null || String(buildCommand).trim() === '') {
|
|
39
|
+
log.info('No build step required — skipping');
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
log.info(`Running: ${buildCommand}`);
|
|
43
|
+
const [cmd, ...args] = String(buildCommand).split(' ');
|
|
39
44
|
await execa(cmd, args, { cwd, stdio: 'inherit', shell: true });
|
|
40
45
|
},
|
|
41
46
|
|
|
@@ -40,13 +40,20 @@ function create(config, cwd) {
|
|
|
40
40
|
},
|
|
41
41
|
|
|
42
42
|
async build() {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
// Explicit null/"" means skip (API-only Rails, etc.). Undefined keeps the
|
|
44
|
+
// conventional assets:precompile default from the Rails detector.
|
|
45
|
+
if (config.buildCommand === null || config.buildCommand === '') {
|
|
46
|
+
log.info('No build step required — skipping');
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const buildCommand =
|
|
50
|
+
config.buildCommand || 'bundle exec rails assets:precompile';
|
|
51
|
+
if (!String(buildCommand).trim()) {
|
|
52
|
+
log.info('No build step required — skipping');
|
|
46
53
|
return;
|
|
47
54
|
}
|
|
48
55
|
log.info(`Running build: ${buildCommand}`);
|
|
49
|
-
const [cmd, ...args] = buildCommand.split(' ');
|
|
56
|
+
const [cmd, ...args] = String(buildCommand).split(' ');
|
|
50
57
|
await execa(cmd, args, { cwd, stdio: 'inherit', shell: true });
|
|
51
58
|
},
|
|
52
59
|
|
package/src/artifact/engine.js
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
getEnvSettings,
|
|
20
20
|
resolveDefaultEnvironmentName,
|
|
21
21
|
} from '../core/config.js';
|
|
22
|
+
import { detectDjangoWsgiPackageDir } from '../utils/python-app-target.js';
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* @param {string} cwd
|
|
@@ -71,21 +72,26 @@ function resolveArtifactType(config) {
|
|
|
71
72
|
*/
|
|
72
73
|
function resolveBuildSettings(config) {
|
|
73
74
|
if (config.projectType === 'both' && config.backend) {
|
|
75
|
+
const buildCommand = config.backend.buildCommand ?? null;
|
|
74
76
|
return {
|
|
75
77
|
buildOutput: config.backend.buildOutput || '.',
|
|
76
78
|
framework: config.backend.framework,
|
|
77
79
|
startCommand: config.backend.startCommand || null,
|
|
78
80
|
port: config.backend.port || 3000,
|
|
79
|
-
buildCommand
|
|
81
|
+
buildCommand,
|
|
80
82
|
};
|
|
81
83
|
}
|
|
82
84
|
|
|
85
|
+
const buildCommand = config.buildCommand ?? null;
|
|
86
|
+
const isBackend = config.projectType === 'backend';
|
|
83
87
|
return {
|
|
84
|
-
|
|
88
|
+
// Backend packages are source trees (or compiled jars/binaries under a known
|
|
89
|
+
// output dir when set). Never default backends to frontend-style "dist".
|
|
90
|
+
buildOutput: config.buildOutput || (isBackend ? '.' : 'dist'),
|
|
85
91
|
framework: config.framework || 'node',
|
|
86
92
|
startCommand: config.startCommand || null,
|
|
87
93
|
port: config.port || 3000,
|
|
88
|
-
buildCommand
|
|
94
|
+
buildCommand,
|
|
89
95
|
};
|
|
90
96
|
}
|
|
91
97
|
|
|
@@ -194,14 +200,47 @@ async function stageBackendArtifact(cwd, stagingDir, config) {
|
|
|
194
200
|
|
|
195
201
|
if (['express', 'nestjs', 'fastify', 'koa', 'nextjs'].includes(framework)) {
|
|
196
202
|
await copyIfExists(cwd, stagingDir, 'package.json');
|
|
197
|
-
|
|
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/).
|
|
198
206
|
await copyIfExists(cwd, stagingDir, 'requirements.txt');
|
|
199
|
-
|
|
200
|
-
|
|
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);
|
|
230
|
+
}
|
|
201
231
|
}
|
|
202
|
-
} else if (['laravel', 'symfony'].includes(framework)) {
|
|
232
|
+
} else if (['laravel', 'symfony', 'php'].includes(framework)) {
|
|
203
233
|
await copyIfExists(cwd, stagingDir, 'composer.json');
|
|
204
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');
|
|
205
244
|
} else if (framework === 'spring') {
|
|
206
245
|
await copyIfExists(cwd, stagingDir, 'pom.xml');
|
|
207
246
|
const targetDir = path.join(cwd, 'target');
|
|
@@ -226,11 +265,19 @@ async function stageBackendArtifact(cwd, stagingDir, config) {
|
|
|
226
265
|
await copyIfExists(cwd, stagingDir, 'Gemfile');
|
|
227
266
|
await copyIfExists(cwd, stagingDir, 'Gemfile.lock');
|
|
228
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');
|
|
229
274
|
} else {
|
|
230
275
|
await copyIfExists(cwd, stagingDir, 'package.json');
|
|
231
276
|
await copyIfExists(cwd, stagingDir, 'requirements.txt');
|
|
232
277
|
}
|
|
233
278
|
|
|
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/.
|
|
234
281
|
if (settings.buildOutput && settings.buildOutput !== '.' && settings.buildOutput !== 'src') {
|
|
235
282
|
const built = path.join(cwd, settings.buildOutput);
|
|
236
283
|
if (await fs.pathExists(built) && !['target', 'bin', 'publish'].includes(settings.buildOutput)) {
|
|
@@ -309,6 +356,7 @@ export async function createArtifact(config, deployedTargets = [], cwd = process
|
|
|
309
356
|
projectType: artifactType,
|
|
310
357
|
framework: settings.framework,
|
|
311
358
|
buildOutput: settings.buildOutput,
|
|
359
|
+
buildCommand: settings.buildCommand,
|
|
312
360
|
startCommand: settings.startCommand,
|
|
313
361
|
port: settings.port,
|
|
314
362
|
generatedBy: getGeneratedByMetadata(),
|
|
@@ -320,6 +368,8 @@ export async function createArtifact(config, deployedTargets = [], cwd = process
|
|
|
320
368
|
metadata.frontend = frontend;
|
|
321
369
|
metadata.backend = {
|
|
322
370
|
framework: settings.framework,
|
|
371
|
+
buildOutput: settings.buildOutput,
|
|
372
|
+
buildCommand: settings.buildCommand,
|
|
323
373
|
startCommand: settings.startCommand,
|
|
324
374
|
port: settings.port,
|
|
325
375
|
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Format an unexpected CLI error for the user — message only, never a stack
|
|
5
|
+
* or pkg snapshot paths like `C:\snapshot\...` / `/snapshot/...`.
|
|
6
|
+
*
|
|
7
|
+
* @param {unknown} err
|
|
8
|
+
* @returns {string}
|
|
9
|
+
*/
|
|
10
|
+
export function formatFatalCliError(err) {
|
|
11
|
+
const raw = err instanceof Error ? err.message : String(err);
|
|
12
|
+
const message = raw
|
|
13
|
+
.split(/\r?\n/)
|
|
14
|
+
.map((line) => line.trim())
|
|
15
|
+
.filter((line) => line && !/[/\\]snapshot[/\\]/i.test(line) && !/^\s*at\s+/.test(line))
|
|
16
|
+
.join(' ')
|
|
17
|
+
.trim() || 'Unknown error';
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
`Unexpected error: ${message}\n` +
|
|
21
|
+
` If this persists, open an issue with the command you ran.`
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {unknown} err
|
|
27
|
+
* @param {(msg: string) => void} [write]
|
|
28
|
+
*/
|
|
29
|
+
export function reportFatalCliError(err, write = console.error) {
|
|
30
|
+
write(chalk.red(formatFatalCliError(err)));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Last-resort handlers so unexpected failures never dump a raw Node stack
|
|
35
|
+
* (especially ugly under pkg: `C:\\snapshot\\DeployHub\\...`).
|
|
36
|
+
*/
|
|
37
|
+
export function installCliFatalHandlers() {
|
|
38
|
+
process.on('uncaughtException', (err) => {
|
|
39
|
+
reportFatalCliError(err);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
});
|
|
42
|
+
process.on('unhandledRejection', (reason) => {
|
|
43
|
+
reportFatalCliError(reason instanceof Error ? reason : new Error(String(reason)));
|
|
44
|
+
process.exit(1);
|
|
45
|
+
});
|
|
46
|
+
}
|
package/src/cli/index.js
CHANGED
|
@@ -17,7 +17,12 @@ import { registerSyncWorkflowsCommand } from '../commands/sync-workflows.js';
|
|
|
17
17
|
import { registerSyncK8sPortsCommand } from '../commands/sync-k8s-ports.js';
|
|
18
18
|
import { registerEnvCommand } from '../commands/env.js';
|
|
19
19
|
import { formatVersionOutput, printBanner, shouldShowBanner } from '../utils/author.js';
|
|
20
|
+
import {
|
|
21
|
+
installCliFatalHandlers,
|
|
22
|
+
reportFatalCliError,
|
|
23
|
+
} from './fatal-error.js';
|
|
20
24
|
|
|
25
|
+
installCliFatalHandlers();
|
|
21
26
|
loadEnv();
|
|
22
27
|
|
|
23
28
|
const program = new Command();
|
|
@@ -48,4 +53,7 @@ registerSyncWorkflowsCommand(program);
|
|
|
48
53
|
registerSyncK8sPortsCommand(program);
|
|
49
54
|
registerEnvCommand(program);
|
|
50
55
|
|
|
51
|
-
program.
|
|
56
|
+
program.parseAsync(process.argv).catch((err) => {
|
|
57
|
+
reportFatalCliError(err);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
});
|
package/src/commands/artifact.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import {
|
|
2
|
+
import { loadEnv } from '../core/config.js';
|
|
3
|
+
import { loadConfigOrExit } from '../core/load-config-or-exit.js';
|
|
3
4
|
import {
|
|
4
5
|
createArtifact,
|
|
5
6
|
listLocalArtifacts,
|
|
@@ -25,7 +26,7 @@ export function registerArtifactCommand(program) {
|
|
|
25
26
|
.description('Create artifact from current build output')
|
|
26
27
|
.action(async () => {
|
|
27
28
|
loadEnv();
|
|
28
|
-
const config = await
|
|
29
|
+
const config = await loadConfigOrExit();
|
|
29
30
|
const result = await createArtifact(config, [], process.cwd());
|
|
30
31
|
console.log(chalk.green(`Artifact created: ${result.artifactDir}`));
|
|
31
32
|
});
|
|
@@ -36,7 +37,7 @@ export function registerArtifactCommand(program) {
|
|
|
36
37
|
.option('--remote', 'Also list builds from remote history.json')
|
|
37
38
|
.action(async (opts) => {
|
|
38
39
|
loadEnv();
|
|
39
|
-
const config = await
|
|
40
|
+
const config = await loadConfigOrExit();
|
|
40
41
|
const artifacts = await listLocalArtifacts();
|
|
41
42
|
|
|
42
43
|
console.log(chalk.bold('\nLocal artifacts:\n'));
|
|
@@ -91,7 +92,7 @@ export function registerArtifactCommand(program) {
|
|
|
91
92
|
.description('Download and extract an artifact by buildId or legacy semver')
|
|
92
93
|
.action(async (versionOrBuildId) => {
|
|
93
94
|
loadEnv();
|
|
94
|
-
const config = await
|
|
95
|
+
const config = await loadConfigOrExit();
|
|
95
96
|
const cwd = process.cwd();
|
|
96
97
|
const needle = String(versionOrBuildId).replace(/^v/i, '');
|
|
97
98
|
|
package/src/commands/build.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import {
|
|
2
|
+
import { loadEnv } from '../core/config.js';
|
|
3
|
+
import { loadConfigOrExit } from '../core/load-config-or-exit.js';
|
|
3
4
|
import { runPipeline } from '../core/pipeline.js';
|
|
4
5
|
import { buildPipelineStages } from '../core/stages.js';
|
|
5
6
|
|
|
@@ -13,14 +14,7 @@ export function registerBuildCommand(program) {
|
|
|
13
14
|
.action(async () => {
|
|
14
15
|
loadEnv();
|
|
15
16
|
const cwd = process.cwd();
|
|
16
|
-
|
|
17
|
-
let config;
|
|
18
|
-
try {
|
|
19
|
-
config = await loadConfig(cwd);
|
|
20
|
-
} catch (err) {
|
|
21
|
-
console.error(chalk.red(err instanceof Error ? err.message : String(err)));
|
|
22
|
-
process.exit(1);
|
|
23
|
-
}
|
|
17
|
+
const config = await loadConfigOrExit(cwd);
|
|
24
18
|
|
|
25
19
|
/** @type {Record<string, unknown>} */
|
|
26
20
|
const state = {};
|
package/src/commands/deploy.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import {
|
|
2
|
+
import { loadEnv } from '../core/config.js';
|
|
3
|
+
import { loadConfigOrExit } from '../core/load-config-or-exit.js';
|
|
3
4
|
import { resolveEnvTargets } from '../core/environments.js';
|
|
4
5
|
import { runPipeline } from '../core/pipeline.js';
|
|
5
6
|
import { listLocalArtifacts } from '../artifact/engine.js';
|
|
@@ -26,7 +27,7 @@ export function registerDeployCommand(program) {
|
|
|
26
27
|
.action(async (opts) => {
|
|
27
28
|
loadEnv();
|
|
28
29
|
const cwd = process.cwd();
|
|
29
|
-
const config = await
|
|
30
|
+
const config = await loadConfigOrExit(cwd);
|
|
30
31
|
const log = createLogger('deploy');
|
|
31
32
|
|
|
32
33
|
let targets;
|
package/src/commands/doctor.js
CHANGED
|
@@ -4,7 +4,8 @@ import fs from 'fs-extra';
|
|
|
4
4
|
import path from 'path';
|
|
5
5
|
import os from 'os';
|
|
6
6
|
import axios from 'axios';
|
|
7
|
-
import {
|
|
7
|
+
import { loadEnv, getEnvMethod, getEnvSettings } from '../core/config.js';
|
|
8
|
+
import { loadConfigOrExit } from '../core/load-config-or-exit.js';
|
|
8
9
|
import {
|
|
9
10
|
isEnvEnabled,
|
|
10
11
|
resolveDefaultEnvironmentName,
|
|
@@ -612,6 +613,41 @@ async function runBackendProcessChecks(config, envName, deployType = 'ssh') {
|
|
|
612
613
|
}
|
|
613
614
|
|
|
614
615
|
if (PYTHON_FRAMEWORKS.has(framework)) {
|
|
616
|
+
checks.push(
|
|
617
|
+
await runCheck('Python 3', async () => {
|
|
618
|
+
const result = await provider.runRemoteCheck('command -v python3');
|
|
619
|
+
if (result.pass) {
|
|
620
|
+
return { name: 'Python 3', pass: true, message: 'python3 found on PATH' };
|
|
621
|
+
}
|
|
622
|
+
return {
|
|
623
|
+
name: 'Python 3',
|
|
624
|
+
pass: false,
|
|
625
|
+
message:
|
|
626
|
+
'python3 not found on PATH — install it on the server ' +
|
|
627
|
+
'(Amazon Linux: sudo yum install -y python3; Ubuntu: sudo apt-get install -y python3)',
|
|
628
|
+
};
|
|
629
|
+
})
|
|
630
|
+
);
|
|
631
|
+
|
|
632
|
+
checks.push(
|
|
633
|
+
await runCheck('pip', async () => {
|
|
634
|
+
// Deploy runs `pip install -r requirements.txt`; accept pip3 or pip.
|
|
635
|
+
const result = await provider.runRemoteCheck(
|
|
636
|
+
'command -v pip3 >/dev/null 2>&1 || command -v pip >/dev/null 2>&1'
|
|
637
|
+
);
|
|
638
|
+
if (result.pass) {
|
|
639
|
+
return { name: 'pip', pass: true, message: 'pip3 or pip found on PATH' };
|
|
640
|
+
}
|
|
641
|
+
return {
|
|
642
|
+
name: 'pip',
|
|
643
|
+
pass: false,
|
|
644
|
+
message:
|
|
645
|
+
'pip/pip3 not found on PATH — install it on the server ' +
|
|
646
|
+
'(Amazon Linux: sudo yum install -y python3-pip; Ubuntu: sudo apt-get install -y python3-pip)',
|
|
647
|
+
};
|
|
648
|
+
})
|
|
649
|
+
);
|
|
650
|
+
|
|
615
651
|
checks.push(
|
|
616
652
|
await runCheck('gunicorn', async () => {
|
|
617
653
|
const result = await provider.runRemoteCheck('which gunicorn || gunicorn --version');
|
|
@@ -705,6 +741,8 @@ export function registerDoctorCommand(program) {
|
|
|
705
741
|
.action(async (opts) => {
|
|
706
742
|
loadEnv();
|
|
707
743
|
const cwd = process.cwd();
|
|
744
|
+
// Doctor requires a project config — fail cleanly before any checks (no null.storage crash).
|
|
745
|
+
const config = await loadConfigOrExit(cwd);
|
|
708
746
|
/** @type {CheckResult[]} */
|
|
709
747
|
const results = [];
|
|
710
748
|
/** @type {Set<string>} names of checks that must not fail the summary */
|
|
@@ -746,17 +784,6 @@ export function registerDoctorCommand(program) {
|
|
|
746
784
|
|
|
747
785
|
results.push(
|
|
748
786
|
await runCheck('Build command', async () => {
|
|
749
|
-
let config;
|
|
750
|
-
try {
|
|
751
|
-
config = await loadConfig(cwd);
|
|
752
|
-
} catch {
|
|
753
|
-
return {
|
|
754
|
-
name: 'Build command',
|
|
755
|
-
pass: false,
|
|
756
|
-
message: 'deployhub.config.json not found — run deployhub init',
|
|
757
|
-
};
|
|
758
|
-
}
|
|
759
|
-
|
|
760
787
|
if (config.projectType === 'backend' && !config.buildCommand) {
|
|
761
788
|
return {
|
|
762
789
|
name: 'Build command',
|
|
@@ -794,14 +821,7 @@ export function registerDoctorCommand(program) {
|
|
|
794
821
|
})
|
|
795
822
|
);
|
|
796
823
|
|
|
797
|
-
|
|
798
|
-
try {
|
|
799
|
-
config = await loadConfig(cwd);
|
|
800
|
-
} catch {
|
|
801
|
-
// handled above
|
|
802
|
-
}
|
|
803
|
-
|
|
804
|
-
if (config) {
|
|
824
|
+
{
|
|
805
825
|
for (const provider of config.storage || []) {
|
|
806
826
|
const label = provider.charAt(0).toUpperCase() + provider.slice(1);
|
|
807
827
|
if (provider === 'aws') {
|
|
@@ -946,10 +966,6 @@ export function registerDoctorCommand(program) {
|
|
|
946
966
|
|
|
947
967
|
results.push(
|
|
948
968
|
await runCheck('Secrets', async () => {
|
|
949
|
-
if (!config) {
|
|
950
|
-
return { name: 'Secrets', pass: false, message: 'No config found' };
|
|
951
|
-
}
|
|
952
|
-
|
|
953
969
|
/** @type {string[]} */
|
|
954
970
|
const required = [];
|
|
955
971
|
for (const provider of config.storage || []) {
|
|
@@ -984,7 +1000,7 @@ export function registerDoctorCommand(program) {
|
|
|
984
1000
|
);
|
|
985
1001
|
|
|
986
1002
|
// Explicit CI-prefixed secret reminders for non-grandfathered environments
|
|
987
|
-
|
|
1003
|
+
{
|
|
988
1004
|
const inCi = process.env.GITHUB_ACTIONS === 'true' || process.env.CI === 'true';
|
|
989
1005
|
for (const envName of Object.keys(config.environments || {})) {
|
|
990
1006
|
const env = config.environments[envName];
|
package/src/commands/env.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import inquirer from 'inquirer';
|
|
3
3
|
import {
|
|
4
|
-
loadConfig,
|
|
5
4
|
loadEnv,
|
|
6
5
|
saveConfig,
|
|
7
6
|
} from '../core/config.js';
|
|
7
|
+
import { loadConfigOrExit } from '../core/load-config-or-exit.js';
|
|
8
8
|
import {
|
|
9
9
|
getEnabledEnvironmentNames,
|
|
10
10
|
getEnvMethod,
|
|
@@ -72,7 +72,7 @@ export function registerEnvCommand(program) {
|
|
|
72
72
|
.description('List configured environments')
|
|
73
73
|
.action(async () => {
|
|
74
74
|
loadEnv();
|
|
75
|
-
const config = await
|
|
75
|
+
const config = await loadConfigOrExit();
|
|
76
76
|
const names = Object.keys(config.environments || {});
|
|
77
77
|
if (names.length === 0) {
|
|
78
78
|
console.log(chalk.yellow('No environments configured. Run: deployhub env add <name>'));
|
|
@@ -117,7 +117,7 @@ export function registerEnvCommand(program) {
|
|
|
117
117
|
.action(async (rawName, opts) => {
|
|
118
118
|
loadEnv();
|
|
119
119
|
const cwd = process.cwd();
|
|
120
|
-
const config = await
|
|
120
|
+
const config = await loadConfigOrExit(cwd);
|
|
121
121
|
const log = createLogger('env');
|
|
122
122
|
|
|
123
123
|
const nameCheck = validateEnvironmentName(rawName, Object.keys(config.environments || {}));
|
|
@@ -233,7 +233,7 @@ export function registerEnvCommand(program) {
|
|
|
233
233
|
.action(async (name) => {
|
|
234
234
|
loadEnv();
|
|
235
235
|
const cwd = process.cwd();
|
|
236
|
-
const config = await
|
|
236
|
+
const config = await loadConfigOrExit(cwd);
|
|
237
237
|
if (!config.environments[name]) {
|
|
238
238
|
console.error(chalk.red(`Environment "${name}" not found.`));
|
|
239
239
|
process.exit(1);
|
|
@@ -251,7 +251,7 @@ export function registerEnvCommand(program) {
|
|
|
251
251
|
.action(async (name) => {
|
|
252
252
|
loadEnv();
|
|
253
253
|
const cwd = process.cwd();
|
|
254
|
-
const config = await
|
|
254
|
+
const config = await loadConfigOrExit(cwd);
|
|
255
255
|
if (!config.environments[name]) {
|
|
256
256
|
console.error(chalk.red(`Environment "${name}" not found.`));
|
|
257
257
|
process.exit(1);
|
|
@@ -269,7 +269,7 @@ export function registerEnvCommand(program) {
|
|
|
269
269
|
.action(async (name) => {
|
|
270
270
|
loadEnv();
|
|
271
271
|
const cwd = process.cwd();
|
|
272
|
-
const config = await
|
|
272
|
+
const config = await loadConfigOrExit(cwd);
|
|
273
273
|
if (!config.environments[name]) {
|
|
274
274
|
console.error(chalk.red(`Environment "${name}" not found.`));
|
|
275
275
|
process.exit(1);
|
package/src/commands/rollback.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import {
|
|
2
|
+
import { loadEnv } from '../core/config.js';
|
|
3
|
+
import { loadConfigOrExit } from '../core/load-config-or-exit.js';
|
|
3
4
|
import { resolveEnvTargets } from '../core/environments.js';
|
|
4
5
|
import {
|
|
5
6
|
rollbackToVersion,
|
|
@@ -23,7 +24,7 @@ export function registerRollbackCommand(program) {
|
|
|
23
24
|
)
|
|
24
25
|
.action(async (versionOrBuildId, opts) => {
|
|
25
26
|
loadEnv();
|
|
26
|
-
const config = await
|
|
27
|
+
const config = await loadConfigOrExit();
|
|
27
28
|
const log = createLogger('rollback');
|
|
28
29
|
|
|
29
30
|
let targets;
|
package/src/commands/storage.js
CHANGED
|
@@ -93,14 +93,8 @@ export function registerStorageCommand(program) {
|
|
|
93
93
|
.description('List configured storage providers and status')
|
|
94
94
|
.action(async () => {
|
|
95
95
|
loadEnv();
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const { loadConfig } = await import('../core/config.js');
|
|
99
|
-
config = await loadConfig();
|
|
100
|
-
} catch {
|
|
101
|
-
console.error(chalk.red('Run deployhub init first'));
|
|
102
|
-
process.exit(1);
|
|
103
|
-
}
|
|
96
|
+
const { loadConfigOrExit } = await import('../core/load-config-or-exit.js');
|
|
97
|
+
const config = await loadConfigOrExit();
|
|
104
98
|
|
|
105
99
|
const results = await testAllProviders(config.storage);
|
|
106
100
|
console.log(chalk.bold('\nStorage Providers:\n'));
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import {
|
|
2
|
+
import { loadEnv } from '../core/config.js';
|
|
3
|
+
import { loadConfigOrExit } from '../core/load-config-or-exit.js';
|
|
3
4
|
import { resolveContainerPort } from '../utils/dockerfile-expose.js';
|
|
4
5
|
import {
|
|
5
6
|
getDefaultKubernetesManifestPaths,
|
|
@@ -23,7 +24,7 @@ export function registerSyncK8sPortsCommand(program) {
|
|
|
23
24
|
.action(async () => {
|
|
24
25
|
loadEnv();
|
|
25
26
|
const cwd = process.cwd();
|
|
26
|
-
const config = await
|
|
27
|
+
const config = await loadConfigOrExit(cwd);
|
|
27
28
|
|
|
28
29
|
const { deploymentPath, servicePath } = getDefaultKubernetesManifestPaths(cwd);
|
|
29
30
|
const hasDeployment = await fs.pathExists(deploymentPath);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import {
|
|
2
|
+
import { loadEnv } from '../core/config.js';
|
|
3
|
+
import { loadConfigOrExit } from '../core/load-config-or-exit.js';
|
|
3
4
|
import { getEnabledEnvironmentNames } from '../core/environments.js';
|
|
4
5
|
import {
|
|
5
6
|
writeWorkflowFile,
|
|
@@ -20,7 +21,7 @@ export function registerSyncWorkflowsCommand(program) {
|
|
|
20
21
|
.action(async () => {
|
|
21
22
|
loadEnv();
|
|
22
23
|
const cwd = process.cwd();
|
|
23
|
-
const config = await
|
|
24
|
+
const config = await loadConfigOrExit(cwd);
|
|
24
25
|
|
|
25
26
|
const storage = config.storage || [];
|
|
26
27
|
const environments = config.environments || {};
|
package/src/commands/verify.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import {
|
|
2
|
+
import { loadEnv } from '../core/config.js';
|
|
3
|
+
import { loadConfigOrExit } from '../core/load-config-or-exit.js';
|
|
3
4
|
import { resolveEnvTargets } from '../core/environments.js';
|
|
4
5
|
import {
|
|
5
6
|
anyEnvHasResolvableHealthCheckUrl,
|
|
@@ -86,7 +87,7 @@ export function registerVerifyCommand(program) {
|
|
|
86
87
|
)
|
|
87
88
|
.action(async (opts) => {
|
|
88
89
|
loadEnv();
|
|
89
|
-
const config = await
|
|
90
|
+
const config = await loadConfigOrExit();
|
|
90
91
|
|
|
91
92
|
let outcome;
|
|
92
93
|
try {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { loadConfig } from './config.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @param {unknown} err
|
|
6
|
+
* @returns {boolean}
|
|
7
|
+
*/
|
|
8
|
+
export function isConfigMissingError(err) {
|
|
9
|
+
return err instanceof Error && /Config not found/i.test(err.message);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* User-facing missing-config message (no stack, no snapshot paths).
|
|
14
|
+
*/
|
|
15
|
+
export function printMissingConfigError() {
|
|
16
|
+
console.error(chalk.red('✗ No deployhub.config.json found in this directory.'));
|
|
17
|
+
console.error(chalk.red(" Run 'deployhub init' first to set up your project."));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Load config or print a clean error and exit (never throws to the caller).
|
|
22
|
+
* @param {string} [cwd]
|
|
23
|
+
* @returns {Promise<import('./config.js').DeployHubConfig>}
|
|
24
|
+
*/
|
|
25
|
+
export async function loadConfigOrExit(cwd = process.cwd()) {
|
|
26
|
+
try {
|
|
27
|
+
return await loadConfig(cwd);
|
|
28
|
+
} catch (err) {
|
|
29
|
+
if (isConfigMissingError(err)) {
|
|
30
|
+
printMissingConfigError();
|
|
31
|
+
} else {
|
|
32
|
+
console.error(chalk.red(err instanceof Error ? err.message : String(err)));
|
|
33
|
+
}
|
|
34
|
+
process.exit(1);
|
|
35
|
+
throw err;
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/core/stages.js
CHANGED
|
@@ -144,6 +144,8 @@ export function buildPipelineStages(config, cwd, state) {
|
|
|
144
144
|
return;
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
// Adapters themselves null-guard buildCommand; this stage always
|
|
148
|
+
// invokes build() so interpreted backends can log the skip cleanly.
|
|
147
149
|
const adapter = getAdapter(ctx.config.framework, ctx.config, ctx.cwd);
|
|
148
150
|
await adapter.build();
|
|
149
151
|
},
|
|
@@ -24,6 +24,32 @@ export const SERVER_DEPLOY_TYPES = [
|
|
|
24
24
|
|
|
25
25
|
const SSH_BASED = ['ssh', 'ec2', 'azure-vm', 'gcp-vm'];
|
|
26
26
|
|
|
27
|
+
const NODE_PM2_FRAMEWORKS = new Set([
|
|
28
|
+
'express',
|
|
29
|
+
'nestjs',
|
|
30
|
+
'fastify',
|
|
31
|
+
'koa',
|
|
32
|
+
'nextjs',
|
|
33
|
+
'node',
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* User-facing label for the backend process identity field (`appName`).
|
|
38
|
+
* Node backends use PM2; other languages use DEPLOYHUB_APP + PID files.
|
|
39
|
+
*
|
|
40
|
+
* @param {string|undefined|null} framework
|
|
41
|
+
* @param {string} projectName
|
|
42
|
+
* @param {'frontend'|'backend'|'both'} projectType
|
|
43
|
+
* @returns {string}
|
|
44
|
+
*/
|
|
45
|
+
export function backendProcessNamePromptMessage(framework, projectName, projectType) {
|
|
46
|
+
const backendFramework = String(framework || '').toLowerCase();
|
|
47
|
+
const usesPm2 = !backendFramework || NODE_PM2_FRAMEWORKS.has(backendFramework);
|
|
48
|
+
const example = projectType === 'both' ? `${projectName}-api` : projectName;
|
|
49
|
+
return usesPm2
|
|
50
|
+
? `PM2 process name for your backend (e.g. ${example}):`
|
|
51
|
+
: `Process name for your backend (identifies this app's process on the server, e.g. ${example}):`;
|
|
52
|
+
}
|
|
27
53
|
/**
|
|
28
54
|
* Prompt for one environment's deployment method + method-specific config.
|
|
29
55
|
* Shared by `deployhub init` and `deployhub env add`.
|
|
@@ -427,7 +453,11 @@ async function promptSshBasedDeployment(base, projectName, projectType, backendC
|
|
|
427
453
|
questions.push({
|
|
428
454
|
type: 'input',
|
|
429
455
|
name: 'appName',
|
|
430
|
-
message:
|
|
456
|
+
message: backendProcessNamePromptMessage(
|
|
457
|
+
/** @type {string|undefined} */ (backendConfig?.framework),
|
|
458
|
+
projectName,
|
|
459
|
+
projectType
|
|
460
|
+
),
|
|
431
461
|
default: projectType === 'both' ? `${projectName}-api` : projectName,
|
|
432
462
|
});
|
|
433
463
|
}
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
} from '../../utils/nginx.js';
|
|
13
13
|
import { resolvePm2AppName } from '../../utils/pm2-app-name.js';
|
|
14
14
|
import { shellQuote, formatRemoteCommandFailure } from '../../utils/shell-quote.js';
|
|
15
|
+
import { extractGunicornTarget } from '../../utils/python-app-target.js';
|
|
15
16
|
|
|
16
17
|
/** @type {Set<string>} */
|
|
17
18
|
const NODE_FRAMEWORKS = new Set(['express', 'nestjs', 'fastify', 'koa', 'nextjs', 'node']);
|
|
@@ -171,8 +172,43 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
171
172
|
await exec(ssh, `pkill -f ${sh(markerJvm)} || true`);
|
|
172
173
|
}
|
|
173
174
|
|
|
175
|
+
/**
|
|
176
|
+
* After starting a backend, wait briefly and confirm the PID file's process
|
|
177
|
+
* is still alive. Not a health check — only catches immediate crash.
|
|
178
|
+
* `port` is closed over from createSshProvider (settings.port / config.port).
|
|
179
|
+
*
|
|
180
|
+
* @param {import('node-ssh').NodeSSH} ssh
|
|
181
|
+
* @param {string} targetPath
|
|
182
|
+
* @param {string} [logFile] — defaults to targetPath/app.log
|
|
183
|
+
*/
|
|
184
|
+
async function assertPidAliveAfterStart(ssh, targetPath, logFile) {
|
|
185
|
+
const pidFile = `${targetPath}/.deployhub.pid`;
|
|
186
|
+
const log = logFile || `${targetPath}/app.log`;
|
|
187
|
+
const verifyCmd =
|
|
188
|
+
`sleep 2; ` +
|
|
189
|
+
`pid="$(cat ${sh(pidFile)} 2>/dev/null | tr -cd '0-9')"; ` +
|
|
190
|
+
`if [ -z "$pid" ] || [ ! -d "/proc/$pid" ]; then ` +
|
|
191
|
+
`echo "DEPLOYHUB_PROCESS_DIED: process exited immediately after start (pidfile=${sh(pidFile)}). Last lines of ${sh(log)}:"; ` +
|
|
192
|
+
`tail -n 40 ${sh(log)} 2>/dev/null || echo "(no app.log)"; ` +
|
|
193
|
+
`exit 1; ` +
|
|
194
|
+
`fi`;
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
await exec(ssh, verifyCmd);
|
|
198
|
+
} catch (err) {
|
|
199
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
200
|
+
throw new Error(
|
|
201
|
+
`Backend process for "${appName}" died immediately after start at ${targetPath}. ` +
|
|
202
|
+
`Check dependencies, entrypoint, and port ${port}.\n${detail}`
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
174
207
|
/**
|
|
175
208
|
* Start a nohup process with DEPLOYHUB_APP marker and write PID file.
|
|
209
|
+
* After launch, wait briefly and confirm the PID is still alive — otherwise
|
|
210
|
+
* surface app.log and fail the deploy (nohup+echo $! alone always "succeeds").
|
|
211
|
+
*
|
|
176
212
|
* @param {import('node-ssh').NodeSSH} ssh
|
|
177
213
|
* @param {string} targetPath
|
|
178
214
|
* @param {string} command — command body after `nohup` (no trailing &)
|
|
@@ -184,6 +220,7 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
184
220
|
ssh,
|
|
185
221
|
`cd ${dir} && DEPLOYHUB_APP=${sh(appName)} nohup ${command} > app.log 2>&1 & echo $! > ${sh(pidFile)}`
|
|
186
222
|
);
|
|
223
|
+
await assertPidAliveAfterStart(ssh, targetPath);
|
|
187
224
|
}
|
|
188
225
|
|
|
189
226
|
/**
|
|
@@ -234,13 +271,21 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
234
271
|
`uvicorn main:app --host 0.0.0.0 --port ${port}`
|
|
235
272
|
);
|
|
236
273
|
} else {
|
|
237
|
-
// gunicorn --daemon writes
|
|
238
|
-
|
|
274
|
+
// gunicorn --daemon writes the master PID to --pid (same .deployhub.pid).
|
|
275
|
+
// --error-logfile + --capture-output give us a log to surface on immediate death
|
|
276
|
+
// (daemonized stdout/stderr otherwise vanish).
|
|
277
|
+
const logFile = `${targetPath}/app.log`;
|
|
278
|
+
const fallbackTarget =
|
|
239
279
|
framework === 'django' ? 'config.wsgi:application' : 'app:app';
|
|
280
|
+
const appTarget =
|
|
281
|
+
extractGunicornTarget(startCommand) || fallbackTarget;
|
|
240
282
|
await exec(
|
|
241
283
|
ssh,
|
|
242
|
-
`cd ${dir} && DEPLOYHUB_APP=${sh(appName)} gunicorn ${appTarget}
|
|
284
|
+
`cd ${dir} && DEPLOYHUB_APP=${sh(appName)} gunicorn ${appTarget} ` +
|
|
285
|
+
`--name ${sh(`deployhub-${appName}`)} --bind 0.0.0.0:${port} ` +
|
|
286
|
+
`--pid ${sh(pidFile)} --error-logfile ${sh(logFile)} --capture-output --daemon`
|
|
243
287
|
);
|
|
288
|
+
await assertPidAliveAfterStart(ssh, targetPath, logFile);
|
|
244
289
|
}
|
|
245
290
|
return;
|
|
246
291
|
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import fs from 'fs-extra';
|
|
2
2
|
import path from 'path';
|
|
3
|
+
import {
|
|
4
|
+
detectDjangoWsgiTarget,
|
|
5
|
+
detectFlaskAppTarget,
|
|
6
|
+
} from '../utils/python-app-target.js';
|
|
3
7
|
|
|
4
8
|
/**
|
|
5
9
|
* @typedef {Object} BackendDetectorResult
|
|
@@ -76,6 +80,7 @@ const FRAMEWORKS = {
|
|
|
76
80
|
defaults: {
|
|
77
81
|
language: 'python',
|
|
78
82
|
buildCommand: null,
|
|
83
|
+
// Fallback only — getBackendInfo overrides via detectDjangoWsgiTarget(cwd)
|
|
79
84
|
startCommand: 'gunicorn config.wsgi:application --bind 0.0.0.0:8000',
|
|
80
85
|
buildOutput: '.',
|
|
81
86
|
testCommand: 'python manage.py test',
|
|
@@ -87,6 +92,7 @@ const FRAMEWORKS = {
|
|
|
87
92
|
defaults: {
|
|
88
93
|
language: 'python',
|
|
89
94
|
buildCommand: null,
|
|
95
|
+
// Fallback only — getBackendInfo overrides via detectFlaskAppTarget(cwd)
|
|
90
96
|
startCommand: 'gunicorn app:app --bind 0.0.0.0:5000',
|
|
91
97
|
buildOutput: '.',
|
|
92
98
|
testCommand: 'pytest',
|
|
@@ -261,6 +267,7 @@ export function getBackendInfo(framework, cwd = process.cwd()) {
|
|
|
261
267
|
let buildCommand = def.defaults.buildCommand;
|
|
262
268
|
let startCommand = def.defaults.startCommand;
|
|
263
269
|
let testCommand = def.defaults.testCommand;
|
|
270
|
+
const port = def.defaults.port;
|
|
264
271
|
|
|
265
272
|
if (def.defaults.language === 'node') {
|
|
266
273
|
if (scripts.build) buildCommand = 'npm run build';
|
|
@@ -268,6 +275,14 @@ export function getBackendInfo(framework, cwd = process.cwd()) {
|
|
|
268
275
|
if (scripts.test) testCommand = 'npm test';
|
|
269
276
|
}
|
|
270
277
|
|
|
278
|
+
if (framework === 'django') {
|
|
279
|
+
const target = detectDjangoWsgiTarget(cwd);
|
|
280
|
+
startCommand = `gunicorn ${target} --bind 0.0.0.0:${port}`;
|
|
281
|
+
} else if (framework === 'flask') {
|
|
282
|
+
const target = detectFlaskAppTarget(cwd);
|
|
283
|
+
startCommand = `gunicorn ${target} --bind 0.0.0.0:${port}`;
|
|
284
|
+
}
|
|
285
|
+
|
|
271
286
|
return {
|
|
272
287
|
projectType: 'backend',
|
|
273
288
|
framework,
|
|
@@ -277,7 +292,7 @@ export function getBackendInfo(framework, cwd = process.cwd()) {
|
|
|
277
292
|
buildOutput: def.defaults.buildOutput,
|
|
278
293
|
testCommand,
|
|
279
294
|
hasDocker,
|
|
280
|
-
port
|
|
295
|
+
port,
|
|
281
296
|
};
|
|
282
297
|
}
|
|
283
298
|
|
package/src/detectors/php.js
CHANGED
|
@@ -6,10 +6,11 @@ function detect(cwd = process.cwd()) {
|
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
function getInfo(cwd = process.cwd()) {
|
|
9
|
+
// Composer install belongs in the install stage, not a compile/build step.
|
|
9
10
|
return {
|
|
10
11
|
framework: 'php',
|
|
11
|
-
buildCommand:
|
|
12
|
-
buildOutput: '
|
|
12
|
+
buildCommand: null,
|
|
13
|
+
buildOutput: '.',
|
|
13
14
|
hasDocker: fs.existsSync(path.join(cwd, 'Dockerfile')),
|
|
14
15
|
};
|
|
15
16
|
}
|
package/src/detectors/python.js
CHANGED
|
@@ -11,10 +11,11 @@ function detect(cwd = process.cwd()) {
|
|
|
11
11
|
|
|
12
12
|
function getInfo(cwd = process.cwd()) {
|
|
13
13
|
const hasDocker = fs.existsSync(path.join(cwd, 'Dockerfile'));
|
|
14
|
+
// Deps install belongs in the install stage (pip), not a compile/build step.
|
|
14
15
|
return {
|
|
15
16
|
framework: 'python',
|
|
16
|
-
buildCommand:
|
|
17
|
-
buildOutput: '
|
|
17
|
+
buildCommand: null,
|
|
18
|
+
buildOutput: '.',
|
|
18
19
|
hasDocker,
|
|
19
20
|
};
|
|
20
21
|
}
|
|
@@ -319,9 +319,17 @@ export function createDockerImageDeployContext(config, env = process.env, log) {
|
|
|
319
319
|
|
|
320
320
|
let reused = false;
|
|
321
321
|
if (!options.skipImageReuse) {
|
|
322
|
+
// Normal deploy: prefer pipeline image (exact tag, then :latest retag).
|
|
322
323
|
reused = await ensureImageFromPipeline(imageRef);
|
|
324
|
+
} else if (await imageExistsLocally(imageRef)) {
|
|
325
|
+
// Rollback: never retag :latest onto an older buildId, but DO use the
|
|
326
|
+
// exact restored buildId image if it is already present locally.
|
|
327
|
+
log.info(`Using restored image ${imageRef} (skipImageReuse — no :latest retag)`);
|
|
328
|
+
reused = true;
|
|
323
329
|
} else {
|
|
324
|
-
log.info(
|
|
330
|
+
log.info(
|
|
331
|
+
`Target image ${imageRef} not found locally — attempting rebuild from artifact`
|
|
332
|
+
);
|
|
325
333
|
}
|
|
326
334
|
|
|
327
335
|
let ranCompose = false;
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
/** Directories to skip when walking for wsgi.py / Flask entrypoints. */
|
|
5
|
+
const SKIP_DIRS = new Set([
|
|
6
|
+
'venv',
|
|
7
|
+
'.venv',
|
|
8
|
+
'env',
|
|
9
|
+
'.env',
|
|
10
|
+
'node_modules',
|
|
11
|
+
'__pycache__',
|
|
12
|
+
'.git',
|
|
13
|
+
'.tox',
|
|
14
|
+
'site-packages',
|
|
15
|
+
'dist',
|
|
16
|
+
'build',
|
|
17
|
+
'.eggs',
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {string} startCommand
|
|
22
|
+
* @returns {string|null} e.g. `myapp.wsgi:application` or `app:app`
|
|
23
|
+
*/
|
|
24
|
+
export function extractGunicornTarget(startCommand) {
|
|
25
|
+
if (!startCommand || typeof startCommand !== 'string') return null;
|
|
26
|
+
const tokens = startCommand.trim().split(/\s+/);
|
|
27
|
+
const gIdx = tokens.findIndex((t) => t === 'gunicorn' || t.endsWith('/gunicorn'));
|
|
28
|
+
if (gIdx < 0) return null;
|
|
29
|
+
for (let i = gIdx + 1; i < tokens.length; i++) {
|
|
30
|
+
const t = tokens[i];
|
|
31
|
+
if (t.startsWith('-')) {
|
|
32
|
+
// skip flag and its value when it looks like `--bind 0.0.0.0:8000`
|
|
33
|
+
if (
|
|
34
|
+
t === '-b' ||
|
|
35
|
+
t === '--bind' ||
|
|
36
|
+
t === '-c' ||
|
|
37
|
+
t === '--config' ||
|
|
38
|
+
t === '-n' ||
|
|
39
|
+
t === '--name' ||
|
|
40
|
+
t === '-p' ||
|
|
41
|
+
t === '--pid' ||
|
|
42
|
+
t === '-w' ||
|
|
43
|
+
t === '--workers' ||
|
|
44
|
+
t === '--chdir' ||
|
|
45
|
+
t === '-e' ||
|
|
46
|
+
t === '--env' ||
|
|
47
|
+
t === '--error-logfile' ||
|
|
48
|
+
t === '--access-logfile' ||
|
|
49
|
+
t === '--log-file'
|
|
50
|
+
) {
|
|
51
|
+
i += 1;
|
|
52
|
+
}
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
// gunicorn app target is module:callable
|
|
56
|
+
if (/^[A-Za-z_][\w.]*:[A-Za-z_]\w*$/.test(t)) {
|
|
57
|
+
return t;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Walk cwd for files named `wsgi.py`, skipping venvs etc.
|
|
65
|
+
* @param {string} cwd
|
|
66
|
+
* @param {number} [maxDepth]
|
|
67
|
+
* @returns {string[]} absolute paths
|
|
68
|
+
*/
|
|
69
|
+
function findWsgiFiles(cwd, maxDepth = 4) {
|
|
70
|
+
/** @type {string[]} */
|
|
71
|
+
const found = [];
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @param {string} dir
|
|
75
|
+
* @param {number} depth
|
|
76
|
+
*/
|
|
77
|
+
function walk(dir, depth) {
|
|
78
|
+
if (depth > maxDepth || found.length >= 20) return;
|
|
79
|
+
let entries;
|
|
80
|
+
try {
|
|
81
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
82
|
+
} catch {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
for (const ent of entries) {
|
|
86
|
+
if (ent.name.startsWith('.') && ent.name !== '.venv') {
|
|
87
|
+
// skip hidden except we already skip .venv via SKIP_DIRS
|
|
88
|
+
if (ent.isDirectory()) continue;
|
|
89
|
+
}
|
|
90
|
+
const full = path.join(dir, ent.name);
|
|
91
|
+
if (ent.isDirectory()) {
|
|
92
|
+
if (SKIP_DIRS.has(ent.name)) continue;
|
|
93
|
+
walk(full, depth + 1);
|
|
94
|
+
} else if (ent.isFile() && ent.name === 'wsgi.py') {
|
|
95
|
+
found.push(full);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
walk(cwd, 0);
|
|
101
|
+
return found;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Convert absolute wsgi.py path under cwd to gunicorn target `pkg.wsgi:application`.
|
|
106
|
+
* @param {string} cwd
|
|
107
|
+
* @param {string} wsgiAbs
|
|
108
|
+
* @returns {string|null}
|
|
109
|
+
*/
|
|
110
|
+
export function wsgiPathToGunicornTarget(cwd, wsgiAbs) {
|
|
111
|
+
const rel = path.relative(cwd, wsgiAbs);
|
|
112
|
+
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
|
|
113
|
+
const noExt = rel.replace(/\.py$/i, '');
|
|
114
|
+
const parts = noExt.split(/[/\\]/).filter(Boolean);
|
|
115
|
+
if (parts.length === 0) return null;
|
|
116
|
+
// Invalid if any segment is not a Python identifier
|
|
117
|
+
if (parts.some((p) => !/^[A-Za-z_]\w*$/.test(p))) return null;
|
|
118
|
+
return `${parts.join('.')}:application`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Package directory containing wsgi.py relative to cwd (e.g. `myapp`), or null if root wsgi.py.
|
|
123
|
+
* @param {string} cwd
|
|
124
|
+
* @returns {string|null} relative dir name, or '' for root-level wsgi.py, or null if none
|
|
125
|
+
*/
|
|
126
|
+
export function detectDjangoWsgiPackageDir(cwd) {
|
|
127
|
+
const wsgiAbs = pickPreferredWsgiFile(cwd);
|
|
128
|
+
if (!wsgiAbs) return null;
|
|
129
|
+
const rel = path.relative(cwd, path.dirname(wsgiAbs));
|
|
130
|
+
if (!rel || rel === '.') return '';
|
|
131
|
+
// Only copy the top-level package segment (myapp/wsgi.py → myapp)
|
|
132
|
+
const top = rel.split(/[/\\]/)[0];
|
|
133
|
+
return top || '';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Prefer standard startproject layout (one level down from manage.py), then config/, then any.
|
|
138
|
+
* @param {string} cwd
|
|
139
|
+
* @returns {string|null} absolute path to wsgi.py
|
|
140
|
+
*/
|
|
141
|
+
function pickPreferredWsgiFile(cwd) {
|
|
142
|
+
const files = findWsgiFiles(cwd);
|
|
143
|
+
if (files.length === 0) return null;
|
|
144
|
+
|
|
145
|
+
const hasManage = fs.existsSync(path.join(cwd, 'manage.py'));
|
|
146
|
+
|
|
147
|
+
// Prefer <pkg>/wsgi.py directly under cwd when manage.py exists (django-admin startproject)
|
|
148
|
+
if (hasManage) {
|
|
149
|
+
const oneLevel = files.filter((f) => {
|
|
150
|
+
const rel = path.relative(cwd, f);
|
|
151
|
+
const parts = rel.split(/[/\\]/);
|
|
152
|
+
return parts.length === 2 && parts[1] === 'wsgi.py';
|
|
153
|
+
});
|
|
154
|
+
if (oneLevel.length === 1) return oneLevel[0];
|
|
155
|
+
// Prefer config/wsgi.py when present among one-level candidates (cookiecutter)
|
|
156
|
+
const configOne = oneLevel.find((f) => path.basename(path.dirname(f)) === 'config');
|
|
157
|
+
if (configOne) return configOne;
|
|
158
|
+
if (oneLevel.length > 0) return oneLevel[0];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const configWsgi = files.find((f) => {
|
|
162
|
+
const rel = path.relative(cwd, f).replace(/\\/g, '/');
|
|
163
|
+
return rel === 'config/wsgi.py';
|
|
164
|
+
});
|
|
165
|
+
if (configWsgi) return configWsgi;
|
|
166
|
+
|
|
167
|
+
const rootWsgi = files.find((f) => path.dirname(f) === path.resolve(cwd));
|
|
168
|
+
if (rootWsgi) return rootWsgi;
|
|
169
|
+
|
|
170
|
+
return files[0];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* @param {string} [cwd]
|
|
175
|
+
* @returns {string} gunicorn target, e.g. `myapp.wsgi:application`
|
|
176
|
+
*/
|
|
177
|
+
export function detectDjangoWsgiTarget(cwd = process.cwd()) {
|
|
178
|
+
const wsgiAbs = pickPreferredWsgiFile(cwd);
|
|
179
|
+
if (!wsgiAbs) return 'config.wsgi:application';
|
|
180
|
+
return wsgiPathToGunicornTarget(cwd, wsgiAbs) || 'config.wsgi:application';
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* @param {string} filePath
|
|
185
|
+
* @returns {'app'|'application'|null}
|
|
186
|
+
*/
|
|
187
|
+
function detectFlaskCallableName(filePath) {
|
|
188
|
+
let content;
|
|
189
|
+
try {
|
|
190
|
+
content = fs.readFileSync(filePath, 'utf-8');
|
|
191
|
+
} catch {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
// Prefer explicit assignment / factory patterns for `app` then `application`
|
|
195
|
+
if (
|
|
196
|
+
/\bapp\s*=\s*/.test(content) ||
|
|
197
|
+
/\bcreate_app\s*\(/.test(content) ||
|
|
198
|
+
/\bFlask\s*\(/.test(content)
|
|
199
|
+
) {
|
|
200
|
+
// If both exist, prefer `application` only when `app` is absent as assignment
|
|
201
|
+
if (/\bapp\s*=\s*/.test(content) || /\bFlask\s*\(/.test(content)) {
|
|
202
|
+
if (/\bapplication\s*=\s*/.test(content) && !/\bapp\s*=\s*/.test(content)) {
|
|
203
|
+
return 'application';
|
|
204
|
+
}
|
|
205
|
+
return 'app';
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (/\bapplication\s*=\s*/.test(content)) return 'application';
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* @param {string} [cwd]
|
|
214
|
+
* @returns {string} gunicorn target, e.g. `app:app`
|
|
215
|
+
*/
|
|
216
|
+
export function detectFlaskAppTarget(cwd = process.cwd()) {
|
|
217
|
+
const candidates = ['app.py', 'wsgi.py', 'application.py'];
|
|
218
|
+
for (const name of candidates) {
|
|
219
|
+
const full = path.join(cwd, name);
|
|
220
|
+
if (!fs.existsSync(full)) continue;
|
|
221
|
+
const callable = detectFlaskCallableName(full);
|
|
222
|
+
if (callable) {
|
|
223
|
+
const mod = name.replace(/\.py$/i, '');
|
|
224
|
+
return `${mod}:${callable}`;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
// Module package app/__init__.py or app/app.py
|
|
228
|
+
const pkgInit = path.join(cwd, 'app', '__init__.py');
|
|
229
|
+
if (fs.existsSync(pkgInit)) {
|
|
230
|
+
const callable = detectFlaskCallableName(pkgInit);
|
|
231
|
+
if (callable) return `app:${callable}`;
|
|
232
|
+
}
|
|
233
|
+
const pkgApp = path.join(cwd, 'app', 'app.py');
|
|
234
|
+
if (fs.existsSync(pkgApp)) {
|
|
235
|
+
const callable = detectFlaskCallableName(pkgApp);
|
|
236
|
+
if (callable) return `app.app:${callable}`;
|
|
237
|
+
}
|
|
238
|
+
return 'app:app';
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export default {
|
|
242
|
+
extractGunicornTarget,
|
|
243
|
+
detectDjangoWsgiTarget,
|
|
244
|
+
detectFlaskAppTarget,
|
|
245
|
+
detectDjangoWsgiPackageDir,
|
|
246
|
+
wsgiPathToGunicornTarget,
|
|
247
|
+
};
|