@akash-chowdhury-24/deployhub 2.0.18 → 2.0.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -2
- package/package.json +1 -1
- package/src/commands/init.js +8 -5
- package/src/deployment/init-helpers.js +12 -0
- package/src/deployment/providers/docker.js +6 -3
- package/src/deployment/providers/kubernetes.js +4 -2
- package/src/deployment/providers/ssh.js +78 -25
- package/src/utils/docker-container-name.js +51 -0
- package/src/utils/github-actions.js +164 -14
- package/src/utils/kube-namespace-name.js +63 -0
- package/src/utils/pm2-app-name.js +71 -0
package/README.md
CHANGED
|
@@ -257,15 +257,78 @@ deployhub logs # last deployment logs
|
|
|
257
257
|
| Argument | Behavior |
|
|
258
258
|
|----------|----------|
|
|
259
259
|
| *(none)* | Rolls back to the **previous** build (second entry in newest-first history) |
|
|
260
|
-
| Exact `buildId` | Rolls back to that specific build |
|
|
260
|
+
| Exact `buildId` | Rolls back to that specific build **if it appears in that env's history** |
|
|
261
261
|
| Semver / version string matching **multiple** builds | Does **not** guess — prints the matching `buildId`s and exits; re-run with an exact one |
|
|
262
262
|
|
|
263
263
|
`buildId` looks like `{semver}-{stamp}` where the stamp is a short git SHA when available, otherwise a CI run id, otherwise a high-resolution timestamp. When `DOCKER_IMAGE_TAG` is left unset, Docker and Kubernetes use that same `buildId` as the image tag — so you can correlate an artifact in storage with the image that was pushed.
|
|
264
264
|
|
|
265
|
+
### Rollback is scoped per environment
|
|
266
|
+
|
|
267
|
+
Each environment maintains its own independent deploy history. When you roll back an environment, DeployHub only considers builds that were actually deployed **to that environment** — never builds deployed to a different environment, even if they're more recent or share the same project.
|
|
268
|
+
|
|
269
|
+
For example: if `development` has deployed builds A, C, and D (in that order), and `production` has separately deployed builds B and D, then rolling back `production` moves it from D to B — **not** to C, even though C is technically a more recent build overall, because C was never deployed to `production` in the first place.
|
|
270
|
+
|
|
271
|
+
You also cannot roll back an environment to a specific `buildId` that was never deployed to that environment — even with `deployhub rollback <buildId> --env <name>`, DeployHub will refuse if that buildId doesn't appear in that environment's own history. This guarantees every rollback returns an environment to a build that was genuinely running there before, never a build it never actually had.
|
|
272
|
+
|
|
273
|
+
Storage layout (per project):
|
|
274
|
+
|
|
275
|
+
```text
|
|
276
|
+
{project}/builds/{buildId}/artifact.zip # immutable build blobs (shared)
|
|
277
|
+
{project}/envs/{env}/history.json # that env's deploy history only
|
|
278
|
+
{project}/envs/{env}/latest/artifact.zip # last successful deploy to that env
|
|
279
|
+
{project}/history.json # build catalog / legacy default-env history
|
|
280
|
+
```
|
|
281
|
+
|
|
265
282
|
For CI-triggered rollback, see [CI rollback](#ci-rollback-deployhub-rollbackyml).
|
|
266
283
|
|
|
267
284
|
---
|
|
268
285
|
|
|
286
|
+
## Multi-environment deployments
|
|
287
|
+
|
|
288
|
+
DeployHub supports multiple named environments in one project (e.g. `development` + `production`), each with its own method, secrets, trigger, and deploy history.
|
|
289
|
+
|
|
290
|
+
### Commands
|
|
291
|
+
|
|
292
|
+
```bash
|
|
293
|
+
deployhub env list
|
|
294
|
+
deployhub env add staging --method ssh # or omit --method for interactive prompts
|
|
295
|
+
deployhub env enable staging
|
|
296
|
+
deployhub env disable staging
|
|
297
|
+
deployhub env remove staging
|
|
298
|
+
|
|
299
|
+
deployhub deploy --env staging
|
|
300
|
+
deployhub deploy --env all # every enabled environment
|
|
301
|
+
|
|
302
|
+
deployhub rollback --env production
|
|
303
|
+
deployhub rollback 1.2.3-abc1234 --env staging
|
|
304
|
+
deployhub rollback --env all # each env rolls back independently
|
|
305
|
+
|
|
306
|
+
deployhub sync-workflows # regenerate deployhub.yml + deployhub-rollback.yml
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
### Trigger defaults
|
|
310
|
+
|
|
311
|
+
| Situation | Default `trigger` |
|
|
312
|
+
|-----------|-------------------|
|
|
313
|
+
| Single-environment `init` | `"push"` — auto-deploy on push to main (original DeployHub behavior) |
|
|
314
|
+
| First / grandfathered env in multi-env `init` | `"push"` |
|
|
315
|
+
| Additional environments | `"manual"` — deploy only via Actions → Run workflow or `deployhub deploy --env` |
|
|
316
|
+
|
|
317
|
+
Multi-env `init` prints a reminder naming which environments are push vs manual and how to edit `deployhub.config.json` (`environments.<name>.trigger`) if you want a different mix. After changing triggers or envs, run `deployhub sync-workflows` and commit the regenerated YAML.
|
|
318
|
+
|
|
319
|
+
On a GitHub Actions **push**, `deployhub build` only auto-deploys environments with `trigger: "push"`. Environments with `trigger: "manual"` are never deployed on push — even though their secrets are present in the job for dispatch/rollback.
|
|
320
|
+
|
|
321
|
+
### Secret naming
|
|
322
|
+
|
|
323
|
+
| Environment | GitHub Secret / env var style |
|
|
324
|
+
|-------------|-------------------------------|
|
|
325
|
+
| Grandfathered / original (`unprefixedSecretEnvironment`) | Unprefixed: `SSH_HOST`, `SSH_KEY`, `DOCKER_IMAGE_NAME`, … |
|
|
326
|
+
| Every additional environment | Prefixed: `PRODUCTION_SSH_HOST`, `STAGING_DOCKER_IMAGE_NAME`, … |
|
|
327
|
+
|
|
328
|
+
Build and Dispatch workflow steps share the **same** secret set (all enabled environments). Trigger only controls which environments are deployed on push — not which secrets are injected.
|
|
329
|
+
|
|
330
|
+
---
|
|
331
|
+
|
|
269
332
|
## Walkthrough: Storage only
|
|
270
333
|
|
|
271
334
|
Use this when you want **versioned build artifacts in the cloud** but deploy manually (or add deployment later).
|
|
@@ -555,6 +618,8 @@ All JS frontends share the same install/build flow: `npm ci` → `npm run build`
|
|
|
555
618
|
- **Install:** Composer (on CI and server).
|
|
556
619
|
- **Deploy:** SSH with PHP-FPM or `php artisan` for Laravel.
|
|
557
620
|
|
|
621
|
+
> ⚠️ **PHP-FPM deployments restart the FPM service for the ENTIRE host on every deploy.** If you run multiple DeployHub-managed environments on the same server, deploying ANY of them will briefly interrupt in-flight requests for ALL of them. For production use with multiple environments, either use separate hosts per environment, or set up per-environment PHP-FPM pools manually (not yet automated by DeployHub).
|
|
622
|
+
|
|
558
623
|
### Java
|
|
559
624
|
|
|
560
625
|
- **Detect:** `pom.xml` with Spring Boot.
|
|
@@ -1031,7 +1096,7 @@ Run `deployhub doctor` after any config change.
|
|
|
1031
1096
|
| `deployhub clean` | Remove old local artifacts |
|
|
1032
1097
|
| `deployhub update` | Check for CLI updates |
|
|
1033
1098
|
|
|
1034
|
-
**Tests:** `npm test` — currently **
|
|
1099
|
+
**Tests:** `npm test` — currently **320 passing** across the Jest suites.
|
|
1035
1100
|
|
|
1036
1101
|
## GitHub Secrets
|
|
1037
1102
|
|
package/package.json
CHANGED
package/src/commands/init.js
CHANGED
|
@@ -36,7 +36,7 @@ import {
|
|
|
36
36
|
printDeploymentNextSteps,
|
|
37
37
|
formatSecretChecklistLine,
|
|
38
38
|
} from '../deployment/deployment-env.js';
|
|
39
|
-
import { confirmValueIfContainsSpaces } from '../deployment/init-helpers.js';
|
|
39
|
+
import { confirmValueIfContainsSpaces, normalizeInitHealthCheckUrl } from '../deployment/init-helpers.js';
|
|
40
40
|
|
|
41
41
|
const FRONTEND_CHOICES = [
|
|
42
42
|
{ name: 'React', value: 'react' },
|
|
@@ -405,10 +405,13 @@ export function registerInitCommand(program) {
|
|
|
405
405
|
|
|
406
406
|
environments[name] = entry;
|
|
407
407
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
408
|
+
// Only persist a health check URL the user actually entered.
|
|
409
|
+
// Never synthesize http://localhost:<port>/health — that always fails
|
|
410
|
+
// from GitHub Actions (remote runner ≠ deploy target) and incorrectly
|
|
411
|
+
// enables the verify stage when the user left the optional prompt blank.
|
|
412
|
+
const answered = normalizeInitHealthCheckUrl(deployAnswers.healthUrl);
|
|
413
|
+
if (answered) {
|
|
414
|
+
healthUrl = answered;
|
|
412
415
|
}
|
|
413
416
|
|
|
414
417
|
const secrets = getDockerEnvSecrets(deployAnswers);
|
|
@@ -32,6 +32,18 @@ export function suggestSshUser(osHint) {
|
|
|
32
32
|
return undefined;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Normalize the optional health-check URL from `deployhub init`.
|
|
37
|
+
* Blank / whitespace → empty string. Never synthesizes localhost defaults
|
|
38
|
+
* (those always fail from GitHub Actions runners, which are not the deploy target).
|
|
39
|
+
*
|
|
40
|
+
* @param {unknown} answer
|
|
41
|
+
* @returns {string}
|
|
42
|
+
*/
|
|
43
|
+
export function normalizeInitHealthCheckUrl(answer) {
|
|
44
|
+
return typeof answer === 'string' && answer.trim() ? answer.trim() : '';
|
|
45
|
+
}
|
|
46
|
+
|
|
35
47
|
/**
|
|
36
48
|
* @param {string} keyPath
|
|
37
49
|
* @returns {Promise<{ ok: boolean, message: string, fixed?: boolean }>}
|
|
@@ -2,6 +2,7 @@ import { execa } from 'execa';
|
|
|
2
2
|
import { createLogger } from '../../logger/index.js';
|
|
3
3
|
import { createDockerImageDeployContext } from '../../utils/docker-image-deploy.js';
|
|
4
4
|
import { resolveDockerImageRefForTag } from '../../utils/docker-image.js';
|
|
5
|
+
import { resolveDockerContainerName } from '../../utils/docker-container-name.js';
|
|
5
6
|
import { getEnvSettings, mergeMethodSettingsIntoEnv } from '../../core/environments.js';
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -15,6 +16,8 @@ export function createDockerProvider(config, envName, env = process.env) {
|
|
|
15
16
|
const effectiveEnv = mergeMethodSettingsIntoEnv(env, settings);
|
|
16
17
|
const imageOps = createDockerImageDeployContext(config, effectiveEnv, log);
|
|
17
18
|
const { fullImage, getDockerEnv, ensureImageReadyForDeploy } = imageOps;
|
|
19
|
+
// Env-scoped like PM2/Nginx — same-daemon multi-env must not share one container name.
|
|
20
|
+
const containerName = resolveDockerContainerName(config, envName);
|
|
18
21
|
|
|
19
22
|
/**
|
|
20
23
|
* @param {string} artifactDir
|
|
@@ -36,11 +39,11 @@ export function createDockerProvider(config, envName, env = process.env) {
|
|
|
36
39
|
|
|
37
40
|
await execa(
|
|
38
41
|
'docker',
|
|
39
|
-
['rm', '-f',
|
|
42
|
+
['rm', '-f', containerName],
|
|
40
43
|
{ stdio: 'pipe', env: dockerEnv }
|
|
41
44
|
).catch(() => {});
|
|
42
45
|
|
|
43
|
-
await execa('docker', ['run', '-d', '--rm', '--name',
|
|
46
|
+
await execa('docker', ['run', '-d', '--rm', '--name', containerName, imageRef], {
|
|
44
47
|
stdio: 'inherit',
|
|
45
48
|
env: dockerEnv,
|
|
46
49
|
});
|
|
@@ -81,7 +84,7 @@ export function createDockerProvider(config, envName, env = process.env) {
|
|
|
81
84
|
try {
|
|
82
85
|
const { stdout } = await execa(
|
|
83
86
|
'docker',
|
|
84
|
-
['ps', '--filter', `name
|
|
87
|
+
['ps', '--filter', `name=^/${containerName}$`, '--format', '{{.Status}}'],
|
|
85
88
|
{ stdio: 'pipe', env: getDockerEnv() }
|
|
86
89
|
);
|
|
87
90
|
return stdout.includes('Up');
|
|
@@ -8,6 +8,7 @@ import { createDockerImageDeployContext } from '../../utils/docker-image-deploy.
|
|
|
8
8
|
import { resolveDockerImageRefForTag } from '../../utils/docker-image.js';
|
|
9
9
|
import { ensureKubernetesNamespace } from '../../utils/kubernetes-namespace.js';
|
|
10
10
|
import { syncKubernetesDeploymentImage } from '../../utils/kubernetes-deploy-image.js';
|
|
11
|
+
import { resolveKubeNamespace } from '../../utils/kube-namespace-name.js';
|
|
11
12
|
import { getEnvSettings, mergeMethodSettingsIntoEnv } from '../../core/environments.js';
|
|
12
13
|
|
|
13
14
|
/**
|
|
@@ -24,8 +25,9 @@ export function createKubernetesProvider(config, envName, env = process.env) {
|
|
|
24
25
|
const kubeconfig =
|
|
25
26
|
effectiveEnv.KUBECONFIG || path.join(os.homedir(), '.kube', 'config');
|
|
26
27
|
const context = effectiveEnv.KUBE_CONTEXT || '';
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
// Env-scoped like Nginx/PM2 — same-cluster multi-env must not share one namespace
|
|
29
|
+
// when settings still default to the project name for every environment.
|
|
30
|
+
const namespace = resolveKubeNamespace(config, envName, effectiveEnv);
|
|
29
31
|
const deploymentName = sanitizeK8sName(config.project || 'app');
|
|
30
32
|
|
|
31
33
|
function getKubectlEnv() {
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
getNginxConfDPath,
|
|
11
11
|
resolveNginxSiteName,
|
|
12
12
|
} from '../../utils/nginx.js';
|
|
13
|
+
import { resolvePm2AppName } from '../../utils/pm2-app-name.js';
|
|
13
14
|
import { shellQuote, formatRemoteCommandFailure } from '../../utils/shell-quote.js';
|
|
14
15
|
|
|
15
16
|
/** @type {Set<string>} */
|
|
@@ -44,8 +45,8 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
44
45
|
settings.frontendDeployPath || deployPath;
|
|
45
46
|
const backendDeployPath =
|
|
46
47
|
settings.backendDeployPath || deployPath;
|
|
47
|
-
|
|
48
|
-
|
|
48
|
+
// Env-scoped like Nginx site names — same-host multi-env must not share one PM2 name.
|
|
49
|
+
const appName = resolvePm2AppName(config, envName, env);
|
|
49
50
|
const port = settings.port || config.port || Number(env.SSH_PORT) || 3000;
|
|
50
51
|
const sshKey = env.SSH_KEY;
|
|
51
52
|
const keyPath = settings.keyPath || env.SSH_KEY_PATH;
|
|
@@ -133,6 +134,58 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
133
134
|
);
|
|
134
135
|
}
|
|
135
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Stop a previously managed non-PM2 backend for THIS env only.
|
|
139
|
+
*
|
|
140
|
+
* PID-file kill is gated: we only signal a PID if /proc shows our
|
|
141
|
+
* DEPLOYHUB_APP / deployhub.app marker in cmdline or environ. A stale PID
|
|
142
|
+
* reused by an unrelated process is left alone (file still removed).
|
|
143
|
+
* Marker-based pkill remains the primary stop for live processes.
|
|
144
|
+
*
|
|
145
|
+
* @param {import('node-ssh').NodeSSH} ssh
|
|
146
|
+
* @param {string} targetPath
|
|
147
|
+
*/
|
|
148
|
+
async function stopScopedBackendProcess(ssh, targetPath) {
|
|
149
|
+
const pidFile = `${targetPath}/.deployhub.pid`;
|
|
150
|
+
const markerEnv = `DEPLOYHUB_APP=${appName}`;
|
|
151
|
+
const markerJvm = `deployhub.app=${appName}`;
|
|
152
|
+
// Verify-then-kill: only signal a PID if /proc shows our marker in cmdline
|
|
153
|
+
// or environ. A stale PID reused by an unrelated process is left alone
|
|
154
|
+
// (the pidfile is still removed so the next start writes a fresh one).
|
|
155
|
+
await exec(
|
|
156
|
+
ssh,
|
|
157
|
+
`if [ -f ${sh(pidFile)} ]; then ` +
|
|
158
|
+
`pid="$(cat ${sh(pidFile)} 2>/dev/null | tr -cd '0-9')"; ` +
|
|
159
|
+
`if [ -n "$pid" ] && [ -r "/proc/$pid/cmdline" ]; then ` +
|
|
160
|
+
`if tr '\\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null | grep -q -F ${sh(markerEnv)} ` +
|
|
161
|
+
`|| tr '\\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null | grep -q -F ${sh(markerJvm)} ` +
|
|
162
|
+
`|| { [ -r "/proc/$pid/environ" ] && tr '\\0' ' ' < "/proc/$pid/environ" 2>/dev/null | grep -q -F ${sh(markerEnv)}; }; then ` +
|
|
163
|
+
`kill "$pid" 2>/dev/null || true; ` +
|
|
164
|
+
`fi; ` +
|
|
165
|
+
`fi; ` +
|
|
166
|
+
`rm -f ${sh(pidFile)}; ` +
|
|
167
|
+
`fi`
|
|
168
|
+
);
|
|
169
|
+
// Marker match — covers lost PID files / processes without a readable pidfile.
|
|
170
|
+
await exec(ssh, `pkill -f ${sh(markerEnv)} || true`);
|
|
171
|
+
await exec(ssh, `pkill -f ${sh(markerJvm)} || true`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Start a nohup process with DEPLOYHUB_APP marker and write PID file.
|
|
176
|
+
* @param {import('node-ssh').NodeSSH} ssh
|
|
177
|
+
* @param {string} targetPath
|
|
178
|
+
* @param {string} command — command body after `nohup` (no trailing &)
|
|
179
|
+
*/
|
|
180
|
+
async function startScopedNohup(ssh, targetPath, command) {
|
|
181
|
+
const pidFile = `${targetPath}/.deployhub.pid`;
|
|
182
|
+
const dir = sh(targetPath);
|
|
183
|
+
await exec(
|
|
184
|
+
ssh,
|
|
185
|
+
`cd ${dir} && DEPLOYHUB_APP=${sh(appName)} nohup ${command} > app.log 2>&1 & echo $! > ${sh(pidFile)}`
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
136
189
|
/**
|
|
137
190
|
* @param {import('node-ssh').NodeSSH} ssh
|
|
138
191
|
* @param {string} targetPath
|
|
@@ -141,6 +194,7 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
141
194
|
const framework = resolveFramework();
|
|
142
195
|
const startCommand = resolveStartCommand();
|
|
143
196
|
const dir = sh(targetPath);
|
|
197
|
+
const pidFile = `${targetPath}/.deployhub.pid`;
|
|
144
198
|
|
|
145
199
|
if (NODE_FRAMEWORKS.has(framework)) {
|
|
146
200
|
await exec(ssh, `cd ${dir} && npm install --production`);
|
|
@@ -172,25 +226,28 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
172
226
|
if (framework === 'django') {
|
|
173
227
|
await exec(ssh, `cd ${dir} && python manage.py migrate`);
|
|
174
228
|
}
|
|
229
|
+
await stopScopedBackendProcess(ssh, targetPath);
|
|
175
230
|
if (framework === 'fastapi') {
|
|
176
|
-
await
|
|
177
|
-
await exec(
|
|
231
|
+
await startScopedNohup(
|
|
178
232
|
ssh,
|
|
179
|
-
|
|
233
|
+
targetPath,
|
|
234
|
+
`uvicorn main:app --host 0.0.0.0 --port ${port}`
|
|
180
235
|
);
|
|
181
236
|
} else {
|
|
182
|
-
|
|
237
|
+
// gunicorn --daemon writes its own PID; still set DEPLOYHUB_APP for pkill fallback.
|
|
183
238
|
const appTarget =
|
|
184
239
|
framework === 'django' ? 'config.wsgi:application' : 'app:app';
|
|
185
240
|
await exec(
|
|
186
241
|
ssh,
|
|
187
|
-
`cd ${dir} &&
|
|
242
|
+
`cd ${dir} && DEPLOYHUB_APP=${sh(appName)} gunicorn ${appTarget} --name ${sh(`deployhub-${appName}`)} --bind 0.0.0.0:${port} --pid ${sh(pidFile)} --daemon`
|
|
188
243
|
);
|
|
189
244
|
}
|
|
190
245
|
return;
|
|
191
246
|
}
|
|
192
247
|
|
|
193
248
|
if (PHP_FRAMEWORKS.has(framework)) {
|
|
249
|
+
// PHP uses a host-wide `systemctl restart php*-fpm` (see README PHP warning).
|
|
250
|
+
// Per-env isolation is Nginx site name + deploy path — not automated FPM pools.
|
|
194
251
|
await exec(ssh, `cd ${dir} && composer install --no-dev`);
|
|
195
252
|
if (framework === 'laravel') {
|
|
196
253
|
await exec(ssh, `cd ${dir} && php artisan migrate --force`);
|
|
@@ -202,40 +259,36 @@ export function createSshProvider(config, envName, env = process.env) {
|
|
|
202
259
|
}
|
|
203
260
|
|
|
204
261
|
if (framework === 'spring' || framework === 'java') {
|
|
205
|
-
await
|
|
206
|
-
|
|
262
|
+
await stopScopedBackendProcess(ssh, targetPath);
|
|
263
|
+
// -Ddeployhub.app= embeds the env-scoped identity in the JVM command line
|
|
264
|
+
// so pkill -f DEPLOYHUB_APP=… and the PID file both target only this env.
|
|
265
|
+
await startScopedNohup(
|
|
207
266
|
ssh,
|
|
208
|
-
|
|
267
|
+
targetPath,
|
|
268
|
+
`java -Ddeployhub.app=${appName} -jar target/*.jar`
|
|
209
269
|
);
|
|
210
270
|
return;
|
|
211
271
|
}
|
|
212
272
|
|
|
213
273
|
if (framework === 'go') {
|
|
214
|
-
await
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
);
|
|
274
|
+
await stopScopedBackendProcess(ssh, targetPath);
|
|
275
|
+
// Binary is always ./bin/app — must NOT pkill by appName alone (that never
|
|
276
|
+
// matched the process) and must NOT pkill a bare "app" (cross-env collision).
|
|
277
|
+
await startScopedNohup(ssh, targetPath, './bin/app');
|
|
219
278
|
return;
|
|
220
279
|
}
|
|
221
280
|
|
|
222
281
|
if (framework === 'dotnet') {
|
|
223
|
-
await
|
|
282
|
+
await stopScopedBackendProcess(ssh, targetPath);
|
|
224
283
|
const dll = startCommand?.replace('dotnet ', '') || 'App.dll';
|
|
225
|
-
await
|
|
226
|
-
ssh,
|
|
227
|
-
`cd ${dir} && nohup dotnet ${dll} > app.log 2>&1 &`
|
|
228
|
-
);
|
|
284
|
+
await startScopedNohup(ssh, targetPath, `dotnet ${dll}`);
|
|
229
285
|
return;
|
|
230
286
|
}
|
|
231
287
|
|
|
232
288
|
if (framework === 'rails') {
|
|
233
289
|
await exec(ssh, `cd ${dir} && bundle install --deployment`);
|
|
234
|
-
await
|
|
235
|
-
await exec
|
|
236
|
-
ssh,
|
|
237
|
-
`cd ${dir} && nohup bundle exec puma -p ${port} > app.log 2>&1 &`
|
|
238
|
-
);
|
|
290
|
+
await stopScopedBackendProcess(ssh, targetPath);
|
|
291
|
+
await startScopedNohup(ssh, targetPath, `bundle exec puma -p ${port}`);
|
|
239
292
|
return;
|
|
240
293
|
}
|
|
241
294
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { isGrandfatheredNginxEnv } from './nginx.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Sanitize a Docker container name (alphanumeric, underscore, hyphen, period).
|
|
5
|
+
* @param {string} name
|
|
6
|
+
* @returns {string}
|
|
7
|
+
*/
|
|
8
|
+
export function sanitizeDockerContainerName(name) {
|
|
9
|
+
return String(name || 'app')
|
|
10
|
+
.replace(/[^a-zA-Z0-9_.-]/g, '-')
|
|
11
|
+
.replace(/^[^a-zA-Z0-9]/, 'a');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Whether this env is the grandfathered / single-env case for container naming.
|
|
16
|
+
* Reuses the same grandfather rule as Nginx / PM2.
|
|
17
|
+
*
|
|
18
|
+
* @param {import('../core/config.js').DeployHubConfig} config
|
|
19
|
+
* @param {string} envName
|
|
20
|
+
* @returns {boolean}
|
|
21
|
+
*/
|
|
22
|
+
export function isGrandfatheredDockerContainerEnv(config, envName) {
|
|
23
|
+
return isGrandfatheredNginxEnv(config, envName);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the Docker container `--name` for an environment.
|
|
28
|
+
*
|
|
29
|
+
* Same risk class as PM2 process names: two docker envs targeting the same
|
|
30
|
+
* daemon with `docker run --name ${project}` will `docker rm -f` each other.
|
|
31
|
+
*
|
|
32
|
+
* - Grandfathered / single-env: `config.project` (unchanged).
|
|
33
|
+
* - Additional envs: `{project}-{env}`.
|
|
34
|
+
*
|
|
35
|
+
* @param {import('../core/config.js').DeployHubConfig} config
|
|
36
|
+
* @param {string} envName
|
|
37
|
+
* @returns {string}
|
|
38
|
+
*/
|
|
39
|
+
export function resolveDockerContainerName(config, envName) {
|
|
40
|
+
const project = sanitizeDockerContainerName(config.project || 'app');
|
|
41
|
+
if (isGrandfatheredDockerContainerEnv(config, envName)) {
|
|
42
|
+
return project;
|
|
43
|
+
}
|
|
44
|
+
return `${project}-${sanitizeDockerContainerName(envName)}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export default {
|
|
48
|
+
sanitizeDockerContainerName,
|
|
49
|
+
isGrandfatheredDockerContainerEnv,
|
|
50
|
+
resolveDockerContainerName,
|
|
51
|
+
};
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import fs from 'fs-extra';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import yaml from 'js-yaml';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import semver from 'semver';
|
|
4
6
|
import { fileURLToPath } from 'url';
|
|
5
|
-
import { getWorkflowHeaderComment } from './author.js';
|
|
7
|
+
import { getWorkflowHeaderComment, getDeployHubVersion } from './author.js';
|
|
6
8
|
import {
|
|
7
9
|
generateDeploymentEnvSection,
|
|
8
10
|
getDeploymentWorkflowSecretKeys,
|
|
@@ -144,28 +146,63 @@ export function getCliInstallSpec(cliSource) {
|
|
|
144
146
|
* Version/range suitable as a package.json dependency VALUE for this CLI
|
|
145
147
|
* (key is already NPM_PACKAGE — do not embed the package name again).
|
|
146
148
|
*
|
|
149
|
+
* Reads the running CLI's package version dynamically. Never hardcode a
|
|
150
|
+
* specific semver fallback (e.g. "1.0.6") — if resolution fails, use "latest".
|
|
151
|
+
*
|
|
147
152
|
* @param {string} [cliSource]
|
|
153
|
+
* @param {{ packageJsonPath?: string }} [opts] — test override: read this package.json
|
|
148
154
|
* @returns {string}
|
|
149
155
|
*/
|
|
150
|
-
export function getCliPackageJsonDependencyVersion(cliSource) {
|
|
156
|
+
export function getCliPackageJsonDependencyVersion(cliSource, opts = {}) {
|
|
151
157
|
const normalized = normalizeCliSource(cliSource);
|
|
152
158
|
if (normalized === DEFAULT_NPM_CLI_SOURCE) {
|
|
159
|
+
const resolved = readCliPackageVersion(opts.packageJsonPath);
|
|
160
|
+
if (resolved) return `^${resolved}`;
|
|
161
|
+
return 'latest';
|
|
162
|
+
}
|
|
163
|
+
// github: / file: specs are valid package.json dependency values as-is
|
|
164
|
+
return getCliInstallSpec(cliSource);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Resolve the running CLI package version for dependency ranges.
|
|
169
|
+
* Order: explicit package.json path (tests) → package.json next to this
|
|
170
|
+
* package root → getDeployHubVersion() (covers __DEPLOYHUB_VERSION__ in
|
|
171
|
+
* pkg binaries). Never returns a hardcoded stale semver.
|
|
172
|
+
*
|
|
173
|
+
* @param {string} [packageJsonPath]
|
|
174
|
+
* @returns {string|null}
|
|
175
|
+
*/
|
|
176
|
+
function readCliPackageVersion(packageJsonPath) {
|
|
177
|
+
const candidates = [];
|
|
178
|
+
if (packageJsonPath) {
|
|
179
|
+
candidates.push(packageJsonPath);
|
|
180
|
+
} else {
|
|
181
|
+
candidates.push(
|
|
182
|
+
path.join(path.dirname(fileURLToPath(import.meta.url)), '../../package.json')
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
for (const pkgPath of candidates) {
|
|
153
187
|
try {
|
|
154
|
-
const pkgPath = path.join(
|
|
155
|
-
path.dirname(fileURLToPath(import.meta.url)),
|
|
156
|
-
'../../package.json'
|
|
157
|
-
);
|
|
158
188
|
const pkg = fs.readJsonSync(pkgPath);
|
|
159
|
-
if (typeof pkg.version === 'string' && pkg.version.trim()) {
|
|
160
|
-
return
|
|
189
|
+
if (typeof pkg.version === 'string' && /^\d+\.\d+\.\d+/.test(pkg.version.trim())) {
|
|
190
|
+
return pkg.version.trim();
|
|
161
191
|
}
|
|
162
192
|
} catch {
|
|
163
|
-
//
|
|
193
|
+
// try next
|
|
164
194
|
}
|
|
165
|
-
return 'latest';
|
|
166
195
|
}
|
|
167
|
-
|
|
168
|
-
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
const v = getDeployHubVersion();
|
|
199
|
+
if (typeof v === 'string' && /^\d+\.\d+\.\d+/.test(v.trim())) {
|
|
200
|
+
return v.trim();
|
|
201
|
+
}
|
|
202
|
+
} catch {
|
|
203
|
+
// ignore
|
|
204
|
+
}
|
|
205
|
+
return null;
|
|
169
206
|
}
|
|
170
207
|
|
|
171
208
|
/**
|
|
@@ -956,20 +993,133 @@ export async function getWorkflowDriftDoctorChecks(cwd, config) {
|
|
|
956
993
|
return checks;
|
|
957
994
|
}
|
|
958
995
|
|
|
996
|
+
/**
|
|
997
|
+
* Extract a comparable base semver from a package.json dependency value
|
|
998
|
+
* (`^2.0.19`, `~2.0.19`, `2.0.19`). Returns null for `latest`, git URLs,
|
|
999
|
+
* or anything that is not a valid semver range/version.
|
|
1000
|
+
*
|
|
1001
|
+
* @param {unknown} value
|
|
1002
|
+
* @returns {string|null}
|
|
1003
|
+
*/
|
|
1004
|
+
export function parseDependencyBaseVersion(value) {
|
|
1005
|
+
if (typeof value !== 'string') return null;
|
|
1006
|
+
const trimmed = value.trim();
|
|
1007
|
+
if (!trimmed) return null;
|
|
1008
|
+
if (
|
|
1009
|
+
trimmed === 'latest' ||
|
|
1010
|
+
trimmed.startsWith('github:') ||
|
|
1011
|
+
trimmed.startsWith('file:') ||
|
|
1012
|
+
trimmed.startsWith('git+') ||
|
|
1013
|
+
trimmed.includes('://')
|
|
1014
|
+
) {
|
|
1015
|
+
return null;
|
|
1016
|
+
}
|
|
1017
|
+
const coerced = semver.coerce(trimmed);
|
|
1018
|
+
return coerced ? coerced.version : null;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/**
|
|
1022
|
+
* Decide whether to write a new DeployHub dependency version into a project
|
|
1023
|
+
* package.json. Never allows a lower semver to overwrite a higher existing pin.
|
|
1024
|
+
*
|
|
1025
|
+
* @param {string|null|undefined} existingValue
|
|
1026
|
+
* @param {string} proposedValue
|
|
1027
|
+
* @returns {{ write: boolean, value: string, warning: string|null }}
|
|
1028
|
+
*/
|
|
1029
|
+
export function decideDeployhubDependencyVersionWrite(existingValue, proposedValue) {
|
|
1030
|
+
const proposed = typeof proposedValue === 'string' ? proposedValue.trim() : '';
|
|
1031
|
+
if (!proposed) {
|
|
1032
|
+
return {
|
|
1033
|
+
write: false,
|
|
1034
|
+
value: typeof existingValue === 'string' ? existingValue : '',
|
|
1035
|
+
warning:
|
|
1036
|
+
'⚠ Skipped updating package.json dependency version: resolved DeployHub version was empty.',
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
if (existingValue == null || existingValue === '') {
|
|
1041
|
+
return { write: true, value: proposed, warning: null };
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
const existingBase = parseDependencyBaseVersion(existingValue);
|
|
1045
|
+
const proposedBase = parseDependencyBaseVersion(proposed);
|
|
1046
|
+
|
|
1047
|
+
if (!existingBase || !proposedBase) {
|
|
1048
|
+
return {
|
|
1049
|
+
write: false,
|
|
1050
|
+
value: String(existingValue),
|
|
1051
|
+
warning:
|
|
1052
|
+
`⚠ Skipped updating package.json dependency version: could not compare ` +
|
|
1053
|
+
`existing "${existingValue}" with resolved "${proposed}" as semver. ` +
|
|
1054
|
+
`Leaving the existing entry untouched.`,
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
if (semver.lt(proposedBase, existingBase)) {
|
|
1059
|
+
return {
|
|
1060
|
+
write: false,
|
|
1061
|
+
value: String(existingValue),
|
|
1062
|
+
warning:
|
|
1063
|
+
`⚠ Skipped updating package.json dependency version: the currently\n` +
|
|
1064
|
+
` resolved DeployHub CLI version (${proposedBase}) is lower than what's already\n` +
|
|
1065
|
+
` pinned (${existingBase}). Keeping the existing, newer version to avoid a\n` +
|
|
1066
|
+
` downgrade. If this is unexpected, check that you're running the\n` +
|
|
1067
|
+
` intended CLI version (which deployhub / npm ls -g @akash-chowdhury-24/deployhub).`,
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// existing <= proposed → write (first-time already handled; upgrade or same)
|
|
1072
|
+
return { write: true, value: proposed, warning: null };
|
|
1073
|
+
}
|
|
1074
|
+
|
|
959
1075
|
/**
|
|
960
1076
|
* @param {string} cliSource
|
|
961
1077
|
* @param {string} [cwd]
|
|
1078
|
+
* @param {{ packageJsonPath?: string, proposedVersion?: string }} [opts]
|
|
1079
|
+
* `proposedVersion` / `packageJsonPath` are for tests (mock resolved CLI version).
|
|
962
1080
|
*/
|
|
963
|
-
export async function addDeployhubToPackageJson(cliSource, cwd = process.cwd()) {
|
|
1081
|
+
export async function addDeployhubToPackageJson(cliSource, cwd = process.cwd(), opts = {}) {
|
|
964
1082
|
const pkgPath = path.join(cwd, 'package.json');
|
|
965
1083
|
if (!(await fs.pathExists(pkgPath))) return;
|
|
966
1084
|
|
|
967
1085
|
const pkg = await fs.readJson(pkgPath);
|
|
968
1086
|
pkg.devDependencies = pkg.devDependencies || {};
|
|
1087
|
+
|
|
1088
|
+
const existingDev = pkg.devDependencies[NPM_PACKAGE];
|
|
1089
|
+
const existingProd =
|
|
1090
|
+
pkg.dependencies && typeof pkg.dependencies === 'object'
|
|
1091
|
+
? pkg.dependencies[NPM_PACKAGE]
|
|
1092
|
+
: undefined;
|
|
1093
|
+
const existing =
|
|
1094
|
+
existingDev != null && existingDev !== ''
|
|
1095
|
+
? existingDev
|
|
1096
|
+
: existingProd != null && existingProd !== ''
|
|
1097
|
+
? existingProd
|
|
1098
|
+
: null;
|
|
1099
|
+
|
|
969
1100
|
// package.json value must be a semver range / "latest" / git URL — never "name@version"
|
|
970
1101
|
// (that form is only for `npm install <spec>` via getCliInstallSpec).
|
|
971
|
-
|
|
1102
|
+
const proposed =
|
|
1103
|
+
typeof opts.proposedVersion === 'string' && opts.proposedVersion.trim()
|
|
1104
|
+
? opts.proposedVersion.trim()
|
|
1105
|
+
: getCliPackageJsonDependencyVersion(cliSource, opts);
|
|
1106
|
+
|
|
1107
|
+
const decision = decideDeployhubDependencyVersionWrite(existing, proposed);
|
|
1108
|
+
if (decision.warning) {
|
|
1109
|
+
console.log(chalk.yellow(decision.warning));
|
|
1110
|
+
}
|
|
1111
|
+
if (decision.write) {
|
|
1112
|
+
// Prefer updating whichever field already held the entry; default to devDependencies.
|
|
1113
|
+
if (existingProd != null && existingProd !== '' && (existingDev == null || existingDev === '')) {
|
|
1114
|
+
pkg.dependencies = pkg.dependencies || {};
|
|
1115
|
+
pkg.dependencies[NPM_PACKAGE] = decision.value;
|
|
1116
|
+
} else {
|
|
1117
|
+
pkg.devDependencies[NPM_PACKAGE] = decision.value;
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
|
|
972
1121
|
delete pkg.devDependencies.deployhub;
|
|
1122
|
+
if (pkg.dependencies) delete pkg.dependencies.deployhub;
|
|
973
1123
|
pkg.scripts = pkg.scripts || {};
|
|
974
1124
|
pkg.scripts['deployhub:build'] = 'deployhub build';
|
|
975
1125
|
await fs.writeJson(pkgPath, pkg, { spaces: 2 });
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { isGrandfatheredNginxEnv } from './nginx.js';
|
|
2
|
+
import { getEnvSettings } from '../core/environments.js';
|
|
3
|
+
import { sanitizeK8sName } from './kubernetes-manifests.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Whether this env is the grandfathered / single-env case for namespace naming.
|
|
7
|
+
* @param {import('../core/config.js').DeployHubConfig} config
|
|
8
|
+
* @param {string} envName
|
|
9
|
+
* @returns {boolean}
|
|
10
|
+
*/
|
|
11
|
+
export function isGrandfatheredKubeNamespaceEnv(config, envName) {
|
|
12
|
+
return isGrandfatheredNginxEnv(config, envName);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolve the Kubernetes namespace for an environment.
|
|
17
|
+
*
|
|
18
|
+
* Deployment *names* stay project-scoped (safe when namespaces differ). Two
|
|
19
|
+
* environments targeting the same cluster with the same namespace WILL collide
|
|
20
|
+
* on Deployment/Service objects — same risk class as PM2/Nginx on one host.
|
|
21
|
+
*
|
|
22
|
+
* - Grandfathered / single-env: settings.kubeNamespace || KUBE_NAMESPACE || project
|
|
23
|
+
* (unchanged).
|
|
24
|
+
* - Additional envs: if configured namespace is missing or equals the project /
|
|
25
|
+
* grandfathered namespace, auto-scope to `{project}-{env}`. An explicitly
|
|
26
|
+
* distinct namespace is kept.
|
|
27
|
+
*
|
|
28
|
+
* @param {import('../core/config.js').DeployHubConfig} config
|
|
29
|
+
* @param {string} envName
|
|
30
|
+
* @param {Record<string, string|undefined>} [env] — already secret-overlaid
|
|
31
|
+
* @returns {string}
|
|
32
|
+
*/
|
|
33
|
+
export function resolveKubeNamespace(config, envName, env = process.env) {
|
|
34
|
+
const project = sanitizeK8sName(config.project || 'app');
|
|
35
|
+
const settings = getEnvSettings(config.environments?.[envName]);
|
|
36
|
+
const configured = String(
|
|
37
|
+
settings.kubeNamespace || env.KUBE_NAMESPACE || ''
|
|
38
|
+
).trim();
|
|
39
|
+
|
|
40
|
+
if (isGrandfatheredKubeNamespaceEnv(config, envName)) {
|
|
41
|
+
return sanitizeK8sName(configured || project);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const grandfather =
|
|
45
|
+
config.unprefixedSecretEnvironment || config.defaultEnvironment || null;
|
|
46
|
+
let grandfatherNs = project;
|
|
47
|
+
if (grandfather && config.environments?.[grandfather]) {
|
|
48
|
+
const gfSettings = getEnvSettings(config.environments[grandfather]);
|
|
49
|
+
grandfatherNs = sanitizeK8sName(gfSettings.kubeNamespace || project);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const defaults = new Set([project, 'default', grandfatherNs]);
|
|
53
|
+
if (configured && !defaults.has(sanitizeK8sName(configured))) {
|
|
54
|
+
return sanitizeK8sName(configured);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return sanitizeK8sName(`${project}-${envName}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export default {
|
|
61
|
+
isGrandfatheredKubeNamespaceEnv,
|
|
62
|
+
resolveKubeNamespace,
|
|
63
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { isGrandfatheredNginxEnv } from './nginx.js';
|
|
2
|
+
import { getEnvSettings } from '../core/environments.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Sanitize a name for PM2 process naming (same charset as Nginx site names).
|
|
6
|
+
* @param {string} name
|
|
7
|
+
* @returns {string}
|
|
8
|
+
*/
|
|
9
|
+
export function sanitizePm2AppName(name) {
|
|
10
|
+
return String(name || 'app').replace(/[^a-zA-Z0-9_-]/g, '-');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Whether this env is the grandfathered / single-env case for process naming.
|
|
15
|
+
* Reuses the same grandfather rule as Nginx site filenames.
|
|
16
|
+
*
|
|
17
|
+
* @param {import('../core/config.js').DeployHubConfig} config
|
|
18
|
+
* @param {string} envName
|
|
19
|
+
* @returns {boolean}
|
|
20
|
+
*/
|
|
21
|
+
export function isGrandfatheredPm2Env(config, envName) {
|
|
22
|
+
return isGrandfatheredNginxEnv(config, envName);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve the PM2 process name for an environment.
|
|
27
|
+
*
|
|
28
|
+
* Same risk class as Nginx site filenames: two backend envs on one host with
|
|
29
|
+
* the same PM2 name will restart/replace each other's process.
|
|
30
|
+
*
|
|
31
|
+
* - Grandfathered / single-env: `settings.appName` || `SSH_APP_NAME` || `project`
|
|
32
|
+
* (unchanged — existing single-env PM2 processes keep their name).
|
|
33
|
+
* - Additional envs: auto-scope to `{project}-{env}` when the configured name
|
|
34
|
+
* is missing, equals the project default, or collides with the grandfathered
|
|
35
|
+
* env's resolved name. An explicitly distinct `appName` / `SSH_APP_NAME` is kept.
|
|
36
|
+
*
|
|
37
|
+
* @param {import('../core/config.js').DeployHubConfig} config
|
|
38
|
+
* @param {string} envName
|
|
39
|
+
* @param {Record<string, string|undefined>} [env] — already secret-overlaid for this env
|
|
40
|
+
* @returns {string}
|
|
41
|
+
*/
|
|
42
|
+
export function resolvePm2AppName(config, envName, env = process.env) {
|
|
43
|
+
const project = sanitizePm2AppName(config.project || 'app');
|
|
44
|
+
const settings = getEnvSettings(config.environments?.[envName]);
|
|
45
|
+
const configured = (settings.appName || env.SSH_APP_NAME || '').trim();
|
|
46
|
+
|
|
47
|
+
if (isGrandfatheredPm2Env(config, envName)) {
|
|
48
|
+
return sanitizePm2AppName(configured || project);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const grandfather =
|
|
52
|
+
config.unprefixedSecretEnvironment || config.defaultEnvironment || null;
|
|
53
|
+
let grandfatherName = project;
|
|
54
|
+
if (grandfather && config.environments?.[grandfather]) {
|
|
55
|
+
const gfSettings = getEnvSettings(config.environments[grandfather]);
|
|
56
|
+
grandfatherName = sanitizePm2AppName(gfSettings.appName || project);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const defaults = new Set([project, `${project}-api`, grandfatherName]);
|
|
60
|
+
if (configured && !defaults.has(sanitizePm2AppName(configured))) {
|
|
61
|
+
return sanitizePm2AppName(configured);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return `${project}-${sanitizePm2AppName(envName)}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export default {
|
|
68
|
+
sanitizePm2AppName,
|
|
69
|
+
isGrandfatheredPm2Env,
|
|
70
|
+
resolvePm2AppName,
|
|
71
|
+
};
|