@akash-chowdhury-24/deployhub 2.0.41 → 2.0.44

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.
@@ -0,0 +1,344 @@
1
+ /**
2
+ * DeployHub-managed .env: GitHub Secret name in config, binary SFTP of the
3
+ * secret value onto the deploy target. SSH methods write deployPath/.env
4
+ * (overwrite every deploy). Docker remote.mode "ssh" writes a per-project-
5
+ * per-env host path and passes --env-file to docker run.
6
+ *
7
+ * Never interpolate secret content into a shell command — the [ssh]/[docker]
8
+ * `$ …` logger prints every exec() string verbatim.
9
+ */
10
+
11
+ import fs from 'fs-extra';
12
+ import os from 'os';
13
+ import path from 'path';
14
+ import { createLogger } from '../logger/index.js';
15
+ import { shellQuote } from '../utils/shell-quote.js';
16
+ import { getEnvMethod, getEnvSettings } from '../core/environments.js';
17
+ import { resolveDockerRemoteMode } from '../utils/docker-remote-mode.js';
18
+ import { prefixSecretKey } from './deployment-env.js';
19
+
20
+ /** Unprefixed GitHub Actions / process.env key after secret overlay. */
21
+ export const MANAGED_ENV_FILE_UNPREFIXED_KEY = 'ENV_FILE';
22
+
23
+ /** Host directory for docker-ssh managed env files (project+env scoped). */
24
+ export const DOCKER_MANAGED_ENV_ROOT = '/opt/deployhub/envs';
25
+
26
+ const SSH_BASED_METHODS = new Set(['ssh', 'ec2', 'azure-vm', 'gcp-vm']);
27
+
28
+ /**
29
+ * @param {unknown} settings
30
+ * @returns {string|null}
31
+ */
32
+ export function getEnvFileSecretName(settings = {}) {
33
+ const name = settings && /** @type {Record<string, unknown>} */ (settings).envFileSecretName;
34
+ if (typeof name !== 'string') return null;
35
+ const trimmed = name.trim();
36
+ return trimmed || null;
37
+ }
38
+
39
+ /**
40
+ * @param {Record<string, unknown>} [settings]
41
+ * @returns {boolean}
42
+ */
43
+ export function envHasManagedEnvFile(settings = {}) {
44
+ return getEnvFileSecretName(settings) != null;
45
+ }
46
+
47
+ /**
48
+ * First environment (init / empty existing list) → ENV_FILE.
49
+ * Additional environments → {ENV}_ENV_FILE (same grandfathering as SSH_HOST).
50
+ *
51
+ * @param {string} envName
52
+ * @param {string[]} [existingEnvNames]
53
+ * @returns {string}
54
+ */
55
+ export function suggestEnvFileSecretName(envName, existingEnvNames = []) {
56
+ if (!Array.isArray(existingEnvNames) || existingEnvNames.length === 0) {
57
+ return MANAGED_ENV_FILE_UNPREFIXED_KEY;
58
+ }
59
+ return prefixSecretKey(envName || 'production', MANAGED_ENV_FILE_UNPREFIXED_KEY);
60
+ }
61
+
62
+ /**
63
+ * @param {string|undefined} method
64
+ * @param {Record<string, unknown>} [settings]
65
+ * @returns {boolean}
66
+ */
67
+ export function envFileSupportedForMethod(method, settings = {}) {
68
+ if (SSH_BASED_METHODS.has(String(method || ''))) return true;
69
+ if (method === 'docker') {
70
+ return resolveDockerRemoteMode(settings) === 'ssh';
71
+ }
72
+ return false;
73
+ }
74
+
75
+ /**
76
+ * Throw if a managed .env is configured on a method that cannot deliver it.
77
+ * No-op when envFileSecretName is unset (additive).
78
+ *
79
+ * @param {string|undefined} method
80
+ * @param {Record<string, unknown>} [settings]
81
+ * @param {string} [envName]
82
+ */
83
+ export function assertEnvFileAllowed(method, settings = {}, envName = 'this environment') {
84
+ if (!envHasManagedEnvFile(settings)) return;
85
+ if (envFileSupportedForMethod(method, settings)) return;
86
+
87
+ let why;
88
+ if (method === 'kubernetes') {
89
+ why = 'Kubernetes is out of scope for DeployHub-managed .env (no SSH file transfer / docker --env-file).';
90
+ } else if (method === 'docker') {
91
+ why =
92
+ `Docker remote.mode "${resolveDockerRemoteMode(settings)}" has no DeployHub-managed SSH session ` +
93
+ `(managed .env requires remote.mode "ssh").`;
94
+ } else {
95
+ why = `Method "${method}" does not support DeployHub-managed .env.`;
96
+ }
97
+ throw new Error(
98
+ `Managed .env is configured for environment "${envName}" but is not supported: ${why} ` +
99
+ `Remove environments.${envName}.config.envFileSecretName or use ssh / ec2 / azure-vm / gcp-vm / docker (remote.mode ssh).`
100
+ );
101
+ }
102
+
103
+ /**
104
+ * Read the secret value from CI env. After applyEnvSecretOverlay the unprefixed
105
+ * ENV_FILE key is populated for prefixed environments. Never log the return value.
106
+ *
107
+ * @param {Record<string, string|undefined>} [env]
108
+ * @param {Record<string, unknown>} [settings]
109
+ * @returns {string|null} null when not configured
110
+ */
111
+ export function readManagedEnvFileContent(env = {}, settings = {}) {
112
+ const name = getEnvFileSecretName(settings);
113
+ if (!name) return null;
114
+ const raw = env[MANAGED_ENV_FILE_UNPREFIXED_KEY] ?? env[name];
115
+ if (raw == null || raw === '') {
116
+ throw new Error(
117
+ `Managed .env is configured (GitHub Secret "${name}") but the value is missing or empty. ` +
118
+ `Add that secret with the FULL CONTENTS of your .env file. ` +
119
+ `DeployHub cannot verify the secret exists ahead of time (GitHub Secrets are write-only).`
120
+ );
121
+ }
122
+ return String(raw);
123
+ }
124
+
125
+ /**
126
+ * @param {string} value
127
+ * @returns {string}
128
+ */
129
+ function sanitizePathSegment(value) {
130
+ return (
131
+ String(value || 'app')
132
+ .replace(/[^a-zA-Z0-9._-]+/g, '-')
133
+ .replace(/^-+|-+$/g, '') || 'app'
134
+ );
135
+ }
136
+
137
+ /**
138
+ * @param {string} remotePath
139
+ * @returns {string}
140
+ */
141
+ function stripTrailingSlash(remotePath) {
142
+ const s = String(remotePath || '').replace(/\\/g, '/');
143
+ if (s.length > 1 && s.endsWith('/')) return s.replace(/\/+$/, '');
144
+ return s || '/';
145
+ }
146
+
147
+ /**
148
+ * @param {string} remotePath
149
+ * @returns {string}
150
+ */
151
+ function posixDirname(remotePath) {
152
+ const normalized = stripTrailingSlash(remotePath);
153
+ const idx = normalized.lastIndexOf('/');
154
+ if (idx <= 0) return '/';
155
+ return normalized.slice(0, idx);
156
+ }
157
+
158
+ /**
159
+ * Docker-ssh host path: /opt/deployhub/envs/<project>-<env>/.env
160
+ * Always project AND environment scoped so two apps on one host never collide.
161
+ *
162
+ * @param {{ project?: string }} config
163
+ * @param {string} envName
164
+ * @returns {string}
165
+ */
166
+ export function resolveDockerManagedEnvRemotePath(config, envName) {
167
+ const project = sanitizePathSegment(config?.project || 'app');
168
+ const env = sanitizePathSegment(envName || 'default');
169
+ return `${DOCKER_MANAGED_ENV_ROOT}/${project}-${env}/.env`;
170
+ }
171
+
172
+ /**
173
+ * SSH methods: deployPath/.env. Full-stack writes both frontend and backend
174
+ * deploy paths (same secret — one environment, two processes).
175
+ *
176
+ * @param {{ projectType?: string }} config
177
+ * @param {Record<string, unknown>} settings
178
+ * @returns {string[]}
179
+ */
180
+ export function resolveSshManagedEnvRemotePaths(config, settings = {}) {
181
+ const projectType = config?.projectType || 'frontend';
182
+ const deployPath = stripTrailingSlash(
183
+ String(settings.deployPath || settings.path || '/var/www/app')
184
+ );
185
+ if (projectType === 'both') {
186
+ const fe = stripTrailingSlash(String(settings.frontendDeployPath || deployPath));
187
+ const be = stripTrailingSlash(String(settings.backendDeployPath || deployPath));
188
+ return [...new Set([`${fe}/.env`, `${be}/.env`])];
189
+ }
190
+ return [`${deployPath}/.env`];
191
+ }
192
+
193
+ /**
194
+ * Write content to a local temp file, SFTP it with ssh.putFile (binary, never
195
+ * shell-interpolated), chmod 600, then delete the local temp file.
196
+ *
197
+ * @param {{
198
+ * ssh: { putFile: Function },
199
+ * session: { exec: Function },
200
+ * content: string,
201
+ * remotePath: string,
202
+ * user: string,
203
+ * log?: { info: Function },
204
+ * }} opts
205
+ */
206
+ export async function uploadManagedEnvFile(opts) {
207
+ const { ssh, session, content, remotePath, user } = opts;
208
+ const log = opts.log || createLogger('deploy');
209
+ if (typeof content !== 'string') {
210
+ throw new Error('Managed .env content must be a string');
211
+ }
212
+
213
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'deployhub-env-'));
214
+ const localPath = path.join(tmpDir, '.env');
215
+ try {
216
+ await fs.writeFile(localPath, content, { encoding: 'utf8', mode: 0o600 });
217
+ const parent = posixDirname(remotePath);
218
+ const parentQ = shellQuote(parent);
219
+ const remoteQ = shellQuote(remotePath);
220
+ const userQ = shellQuote(user);
221
+ log.info(`Uploading managed .env to ${remotePath}`);
222
+ const dockerHostRoot = '/opt/deployhub';
223
+ const underDockerRoot =
224
+ parent === DOCKER_MANAGED_ENV_ROOT || parent.startsWith(`${DOCKER_MANAGED_ENV_ROOT}/`);
225
+ // Chown /opt/deployhub (not just the leaf) so later projects can mkdir as the
226
+ // deploy user without sudo — same idea as README `chown $USER:$USER /opt/deployhub`.
227
+ const chownTarget = underDockerRoot ? dockerHostRoot : parent;
228
+ const chownQ = shellQuote(chownTarget);
229
+ try {
230
+ await session.exec(ssh, `mkdir -p ${parentQ}`);
231
+ } catch {
232
+ try {
233
+ await session.exec(
234
+ ssh,
235
+ `sudo -n mkdir -p ${parentQ} && sudo -n chown -R ${userQ}:${userQ} ${chownQ}`
236
+ );
237
+ } catch {
238
+ throw new Error(
239
+ `Cannot create ${parent} for managed .env (the SSH user cannot write there).\n` +
240
+ ` Run this on your server once:\n` +
241
+ ` sudo mkdir -p ${parent}\n` +
242
+ ` sudo chown ${user}:${user} ${chownTarget}\n` +
243
+ ` (or grant passwordless sudo for mkdir/chown).`
244
+ );
245
+ }
246
+ }
247
+ await session.exec(
248
+ ssh,
249
+ `if [ ! -w ${parentQ} ]; then sudo -n chown ${userQ}:${userQ} ${parentQ}; fi`
250
+ );
251
+ await ssh.putFile(localPath, remotePath);
252
+ await session.exec(ssh, `chmod 600 ${remoteQ}`);
253
+ try {
254
+ await session.exec(ssh, `chown ${userQ}:${userQ} ${remoteQ}`);
255
+ } catch {
256
+ await session.exec(ssh, `sudo -n chown ${userQ}:${userQ} ${remoteQ}`);
257
+ }
258
+ } finally {
259
+ await fs.remove(tmpDir).catch(() => {});
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Collect secret names referenced as ${{ secrets.NAME }} in workflow YAML.
265
+ * Local copy so env-file.js does not import github-actions.js (cycle risk).
266
+ *
267
+ * @param {string} yaml
268
+ * @returns {string[]}
269
+ */
270
+ function extractWorkflowSecretKeys(yaml) {
271
+ /** @type {Set<string>} */
272
+ const keys = new Set();
273
+ const re = /\$\{\{\s*secrets\.([A-Z0-9_]+)\s*\}\}/g;
274
+ let match;
275
+ while ((match = re.exec(String(yaml || ''))) !== null) {
276
+ keys.add(match[1]);
277
+ }
278
+ return [...keys];
279
+ }
280
+
281
+ /**
282
+ * Informational doctor lines — pass: true, never blocks.
283
+ * Cannot verify the secret VALUE (GitHub Secrets are write-only).
284
+ *
285
+ * @param {Record<string, unknown>} config
286
+ * @param {{ workflowText?: string }} [options]
287
+ * @returns {{ name: string, pass: boolean, message: string }[]}
288
+ */
289
+ export function getEnvFileDoctorChecks(config, options = {}) {
290
+ const envs = /** @type {Record<string, unknown>} */ (config.environments || {});
291
+ const workflowKeys = new Set(extractWorkflowSecretKeys(options.workflowText || ''));
292
+ const hasWorkflowText =
293
+ typeof options.workflowText === 'string' && options.workflowText.length > 0;
294
+ /** @type {{ name: string, pass: boolean, message: string }[]} */
295
+ const checks = [];
296
+
297
+ for (const [name, entry] of Object.entries(envs)) {
298
+ const settings = getEnvSettings(entry);
299
+ const secretName = getEnvFileSecretName(settings);
300
+ if (!secretName) continue;
301
+ const method = getEnvMethod(entry);
302
+ const supported = envFileSupportedForMethod(method, settings);
303
+ const listed = workflowKeys.has(secretName);
304
+ let listing;
305
+ if (!hasWorkflowText) {
306
+ listing =
307
+ `Workflow file not available here — cannot confirm "${secretName}" is in the secret list. ` +
308
+ `Run deployhub sync-workflows after enabling managed .env.`;
309
+ } else if (listed) {
310
+ listing = `GitHub Secret "${secretName}" is referenced in the deploy workflow's secret list.`;
311
+ } else {
312
+ listing =
313
+ `GitHub Secret "${secretName}" is NOT referenced in the deploy workflow ` +
314
+ `(run deployhub sync-workflows).`;
315
+ }
316
+ const limitation =
317
+ 'Secret VALUES cannot be verified (GitHub Secrets are write-only).';
318
+ const overwrite =
319
+ 'DeployHub-managed .env is overwritten every deploy; do not also hand-place a .env for this environment.';
320
+ checks.push({
321
+ name: `Managed .env (${name})`,
322
+ pass: true,
323
+ message: supported
324
+ ? `Managed .env for '${name}': ${listing} ${limitation} ${overwrite}`
325
+ : `Managed .env configured for '${name}' (secret "${secretName}") but ${method} does not deliver it — remove envFileSecretName or use an SSH-based method / docker remote.mode ssh. ${limitation}`,
326
+ });
327
+ }
328
+ return checks;
329
+ }
330
+
331
+ export default {
332
+ MANAGED_ENV_FILE_UNPREFIXED_KEY,
333
+ DOCKER_MANAGED_ENV_ROOT,
334
+ getEnvFileSecretName,
335
+ envHasManagedEnvFile,
336
+ suggestEnvFileSecretName,
337
+ envFileSupportedForMethod,
338
+ assertEnvFileAllowed,
339
+ readManagedEnvFileContent,
340
+ resolveDockerManagedEnvRemotePath,
341
+ resolveSshManagedEnvRemotePaths,
342
+ uploadManagedEnvFile,
343
+ getEnvFileDoctorChecks,
344
+ };
@@ -1,7 +1,9 @@
1
1
  /**
2
- * Per-environment remote shell hooks (preDeploy / postDeploy / rollback).
2
+ * Per-environment remote shell hooks (preDeploy / postInstall / postDeploy / rollback).
3
3
  * One implementation for ssh, ec2, azure-vm, gcp-vm, and docker remote.mode ssh.
4
4
  * Callers must pass the deploy's existing SSH session — do not connect again.
5
+ * `postInstall` is invoked only from ssh.js `runBackendStartSequence` (plain SSH
6
+ * methods). Docker-ssh never runs it — image build already installed dependencies.
5
7
  */
6
8
 
7
9
  import { createLogger } from '../logger/index.js';
@@ -9,10 +11,10 @@ import { formatRemoteCommandFailure } from '../utils/shell-quote.js';
9
11
  import { getEnvMethod, getEnvSettings } from '../core/environments.js';
10
12
  import { resolveDockerRemoteMode } from '../utils/docker-remote-mode.js';
11
13
 
12
- /** @typedef {'preDeploy'|'postDeploy'|'rollback'} HookStage */
14
+ /** @typedef {'preDeploy'|'postInstall'|'postDeploy'|'rollback'} HookStage */
13
15
 
14
16
  /** @type {HookStage[]} */
15
- export const HOOK_STAGES = ['preDeploy', 'postDeploy', 'rollback'];
17
+ export const HOOK_STAGES = ['preDeploy', 'postInstall', 'postDeploy', 'rollback'];
16
18
 
17
19
  const SSH_BASED_METHODS = new Set(['ssh', 'ec2', 'azure-vm', 'gcp-vm']);
18
20
 
@@ -75,16 +77,17 @@ function normalizeHookList(raw) {
75
77
 
76
78
  /**
77
79
  * @param {Record<string, unknown>} [settings]
78
- * @returns {{ preDeploy: ReturnType<typeof normalizeHookList>, postDeploy: ReturnType<typeof normalizeHookList>, rollback: ReturnType<typeof normalizeHookList> }}
80
+ * @returns {{ preDeploy: ReturnType<typeof normalizeHookList>, postInstall: ReturnType<typeof normalizeHookList>, postDeploy: ReturnType<typeof normalizeHookList>, rollback: ReturnType<typeof normalizeHookList> }}
79
81
  */
80
82
  export function getEnvHooks(settings = {}) {
81
83
  const raw = settings.hooks;
82
84
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
83
- return { preDeploy: [], postDeploy: [], rollback: [] };
85
+ return { preDeploy: [], postInstall: [], postDeploy: [], rollback: [] };
84
86
  }
85
87
  const h = /** @type {Record<string, unknown>} */ (raw);
86
88
  return {
87
89
  preDeploy: normalizeHookList(h.preDeploy),
90
+ postInstall: normalizeHookList(h.postInstall),
88
91
  postDeploy: normalizeHookList(h.postDeploy),
89
92
  rollback: normalizeHookList(h.rollback),
90
93
  };
@@ -96,7 +99,13 @@ export function getEnvHooks(settings = {}) {
96
99
  */
97
100
  export function envHasAnyHooks(settings = {}) {
98
101
  const h = getEnvHooks(settings);
99
- return h.preDeploy.length + h.postDeploy.length + h.rollback.length > 0;
102
+ return (
103
+ h.preDeploy.length +
104
+ h.postInstall.length +
105
+ h.postDeploy.length +
106
+ h.rollback.length >
107
+ 0
108
+ );
100
109
  }
101
110
 
102
111
  /**
@@ -241,6 +250,7 @@ export function getHooksDoctorChecks(config) {
241
250
  const h = getEnvHooks(settings);
242
251
  const parts = [];
243
252
  if (h.preDeploy.length) parts.push(`${h.preDeploy.length} preDeploy`);
253
+ if (h.postInstall.length) parts.push(`${h.postInstall.length} postInstall`);
244
254
  if (h.postDeploy.length) parts.push(`${h.postDeploy.length} postDeploy`);
245
255
  if (h.rollback.length) parts.push(`${h.rollback.length} rollback`);
246
256
  const method = getEnvMethod(entry);
@@ -11,6 +11,7 @@ import {
11
11
  getEnvSettings,
12
12
  } from '../core/environments.js';
13
13
  import { assertHooksAllowed } from './hooks.js';
14
+ import { assertEnvFileAllowed } from './env-file.js';
14
15
  import { applyEnvSecretOverlay } from './deployment-env.js';
15
16
  import { recordEnvDeployment } from '../storage/index.js';
16
17
  import { buildArtifactRemoteKey } from '../utils/build-id.js';
@@ -84,6 +85,7 @@ export async function deployToAll(config, artifactDir, envNames) {
84
85
 
85
86
  const method = getEnvMethod(envConfig);
86
87
  assertHooksAllowed(method, getEnvSettings(envConfig), envName);
88
+ assertEnvFileAllowed(method, getEnvSettings(envConfig), envName);
87
89
  const provider = getDeploymentProvider(method, config, envName);
88
90
  log.info(`Deploying to ${envName} (${method})...`);
89
91
  await provider.deploy(artifactDir);
@@ -133,6 +135,7 @@ export async function rollbackAll(config, artifactDir, envNames, meta) {
133
135
  const envConfig = config.environments[envName];
134
136
  const method = getEnvMethod(envConfig);
135
137
  assertHooksAllowed(method, getEnvSettings(envConfig), envName);
138
+ assertEnvFileAllowed(method, getEnvSettings(envConfig), envName);
136
139
  const provider = getDeploymentProvider(method, config, envName);
137
140
  await provider.rollback(artifactDir, meta);
138
141
  }
@@ -1,6 +1,7 @@
1
1
  import inquirer from 'inquirer';
2
2
  import chalk from 'chalk';
3
3
  import { createEnvNamePromptValidate, normalizeGitBranchName } from '../core/environments.js';
4
+ import { suggestEnvFileSecretName } from './env-file.js';
4
5
  import {
5
6
  suggestSshUser,
6
7
  listKubeContexts,
@@ -154,12 +155,17 @@ export async function promptServerDeployment(
154
155
 
155
156
  const triggerMeta = await promptTriggerAndBranch(options);
156
157
  const hookMeta = await promptDeployHooksIfSupported(deployType, methodAnswers);
157
- return { ...methodAnswers, ...triggerMeta, ...hookMeta };
158
+ const envFileMeta = await promptManagedEnvFileIfSupported(deployType, methodAnswers, {
159
+ existingEnvNames,
160
+ envName: methodAnswers.envName || options.envName,
161
+ });
162
+ return { ...methodAnswers, ...triggerMeta, ...hookMeta, ...envFileMeta };
158
163
  }
159
164
 
160
165
  /**
161
- * Optional pre/post/rollback commands. Skipped unless the method has a
162
- * DeployHub-managed SSH session (ssh / ec2 / azure-vm / gcp-vm / docker-ssh).
166
+ * Optional pre/post-install/post/rollback commands. Skipped unless the method
167
+ * has a DeployHub-managed SSH session (ssh / ec2 / azure-vm / gcp-vm / docker-ssh).
168
+ * `postInstall` is ssh-based only — Docker-ssh installs deps at image build time.
163
169
  *
164
170
  * @param {string} deployType
165
171
  * @param {Record<string, unknown>} methodAnswers
@@ -170,7 +176,65 @@ async function promptDeployHooksIfSupported(deployType, methodAnswers) {
170
176
  if (!sshBased && !dockerSsh) {
171
177
  return {};
172
178
  }
173
- return promptDeployHooks();
179
+ return promptDeployHooks({ includePostInstall: sshBased });
180
+ }
181
+
182
+ /**
183
+ * Optional DeployHub-managed .env (GitHub Secret). Same method scope as hooks:
184
+ * ssh / ec2 / azure-vm / gcp-vm / docker-ssh. `--yes` never enters this path.
185
+ *
186
+ * @param {string} deployType
187
+ * @param {Record<string, unknown>} methodAnswers
188
+ * @param {{ existingEnvNames?: string[], envName?: string }} [options]
189
+ */
190
+ async function promptManagedEnvFileIfSupported(deployType, methodAnswers, options = {}) {
191
+ const sshBased = SSH_BASED.includes(deployType);
192
+ const dockerSsh = deployType === 'docker' && methodAnswers.remoteMode === 'ssh';
193
+ if (!sshBased && !dockerSsh) {
194
+ return {};
195
+ }
196
+ return promptManagedEnvFile({
197
+ envName: options.envName || methodAnswers.envName || 'production',
198
+ existingEnvNames: options.existingEnvNames || [],
199
+ });
200
+ }
201
+
202
+ /**
203
+ * @param {{ envName?: string, existingEnvNames?: string[] }} [options]
204
+ * @returns {Promise<{ envFileSecretName?: string }>}
205
+ */
206
+ export async function promptManagedEnvFile(options = {}) {
207
+ const envName = options.envName || 'production';
208
+ const existingEnvNames = options.existingEnvNames || [];
209
+ const suggested = suggestEnvFileSecretName(envName, existingEnvNames);
210
+
211
+ const { deployEnvFile } = await inquirer.prompt([
212
+ {
213
+ type: 'confirm',
214
+ name: 'deployEnvFile',
215
+ message: 'Does this project have a .env file that should be deployed to the server?',
216
+ default: false,
217
+ },
218
+ ]);
219
+ if (!deployEnvFile) return {};
220
+
221
+ console.log(
222
+ chalk.gray(
223
+ `\n Paste the FULL CONTENTS of your .env file as a GitHub Secret named ${suggested}, then confirm below.\n` +
224
+ ` DeployHub cannot verify the secret exists ahead of time (GitHub Secrets are write-only).\n`
225
+ )
226
+ );
227
+
228
+ await inquirer.prompt([
229
+ {
230
+ type: 'confirm',
231
+ name: 'envFileSecretAdded',
232
+ message: `Confirm once you've added the secret ${suggested}`,
233
+ default: false,
234
+ },
235
+ ]);
236
+
237
+ return { envFileSecretName: suggested };
174
238
  }
175
239
 
176
240
  /**
@@ -247,11 +311,13 @@ async function promptHookStageCommands(opts) {
247
311
  }
248
312
 
249
313
  /**
314
+ * @param {{ includePostInstall?: boolean }} [options]
250
315
  * @returns {Promise<{ hooks?: Record<string, { command: string, continueOnError: boolean }[]> }>}
251
316
  */
252
- export async function promptDeployHooks() {
317
+ export async function promptDeployHooks(options = {}) {
253
318
  /** @type {Record<string, { command: string, continueOnError: boolean }[]>} */
254
319
  const hooks = {};
320
+ const includePostInstall = options.includePostInstall !== false;
255
321
 
256
322
  const preDeploy = await promptHookStageCommands({
257
323
  firstConfirmName: 'addPreDeploy',
@@ -263,6 +329,19 @@ export async function promptDeployHooks() {
263
329
  });
264
330
  if (preDeploy.length) hooks.preDeploy = preDeploy;
265
331
 
332
+ if (includePostInstall) {
333
+ const postInstall = await promptHookStageCommands({
334
+ firstConfirmName: 'addPostInstall',
335
+ firstMessage:
336
+ 'Add a post-install command? (e.g. prisma generate, collectstatic — runs after dependencies install, before the app starts)',
337
+ anotherMessage: 'Add another post-install command?',
338
+ commandMessage: 'Post-install command:',
339
+ abortMessage: 'Abort deploy if this command fails?',
340
+ abortDefault: true,
341
+ });
342
+ if (postInstall.length) hooks.postInstall = postInstall;
343
+ }
344
+
266
345
  const postDeploy = await promptHookStageCommands({
267
346
  firstConfirmName: 'addPostDeploy',
268
347
  firstMessage: 'Add a post-deploy command? (e.g. clear cache, notify)',
@@ -885,6 +964,7 @@ export function buildServerEnvEntry(
885
964
  settings.port = n;
886
965
  }
887
966
  attachHooksFromAnswers(settings, deployAnswers);
967
+ attachEnvFileFromAnswers(settings, deployAnswers);
888
968
  return withTriggerAndBranch(
889
969
  {
890
970
  enabled: true,
@@ -931,6 +1011,7 @@ export function buildServerEnvEntry(
931
1011
  }
932
1012
 
933
1013
  attachHooksFromAnswers(settings, deployAnswers);
1014
+ attachEnvFileFromAnswers(settings, deployAnswers);
934
1015
  return withTriggerAndBranch(
935
1016
  {
936
1017
  enabled: true,
@@ -951,11 +1032,23 @@ function attachHooksFromAnswers(settings, deployAnswers) {
951
1032
  const raw = deployAnswers && deployAnswers.hooks;
952
1033
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return;
953
1034
  const hooks = /** @type {Record<string, unknown>} */ (raw);
954
- if (hooks.preDeploy || hooks.postDeploy || hooks.rollback) {
1035
+ if (hooks.preDeploy || hooks.postInstall || hooks.postDeploy || hooks.rollback) {
955
1036
  settings.hooks = hooks;
956
1037
  }
957
1038
  }
958
1039
 
1040
+ /**
1041
+ * Store the GitHub Secret *name* only — never the .env contents.
1042
+ * @param {Record<string, unknown>} settings
1043
+ * @param {Record<string, unknown>} deployAnswers
1044
+ */
1045
+ function attachEnvFileFromAnswers(settings, deployAnswers) {
1046
+ const name = deployAnswers && deployAnswers.envFileSecretName;
1047
+ if (typeof name === 'string' && name.trim()) {
1048
+ settings.envFileSecretName = name.trim();
1049
+ }
1050
+ }
1051
+
959
1052
  /**
960
1053
  * Overlay prompt answers onto the env entry. `--yes` / missing answers keep
961
1054
  * trigger `manual` and omit `branch` (backward compatible).
@@ -6,6 +6,12 @@ import { resolveDockerContainerName } from '../../utils/docker-container-name.js
6
6
  import { getEnvSettings, mergeMethodSettingsIntoEnv } from '../../core/environments.js';
7
7
  import { createSshExecSession } from '../ssh-connection.js';
8
8
  import { assertHooksAllowed, runDeployHooks } from '../hooks.js';
9
+ import {
10
+ assertEnvFileAllowed,
11
+ readManagedEnvFileContent,
12
+ resolveDockerManagedEnvRemotePath,
13
+ uploadManagedEnvFile,
14
+ } from '../env-file.js';
9
15
  import { resolveDockerRemoteMode } from '../../utils/docker-remote-mode.js';
10
16
  import {
11
17
  resolveDockerSshTarget,
@@ -68,6 +74,10 @@ export function createDockerProvider(config, envName, env = process.env) {
68
74
  */
69
75
  async function deploy(artifactDir, options = {}) {
70
76
  assertHooksAllowed('docker', settings, envName);
77
+ assertEnvFileAllowed('docker', settings, envName);
78
+ // Fail fast (before image pull / SSH): missing or empty GitHub Secret must
79
+ // not reach uploadManagedEnvFile. Actions injects "" for a never-created name.
80
+ readManagedEnvFileContent(effectiveEnv, settings);
71
81
  const imageRef = options.fullImage || fullImage;
72
82
  log.info(`Deploying via Docker (image: ${imageRef})...`);
73
83
 
@@ -159,7 +169,15 @@ export function createDockerProvider(config, envName, env = process.env) {
159
169
  */
160
170
  async function deployOverSsh(imageRef, port, options = {}) {
161
171
  const isRollback = options.isRollback === true;
162
- const cmds = buildRemoteDockerCommands(imageRef, containerName, {}, { publishPort: port });
172
+ // Current secret value (not the old build's). Rollback re-delivers this too —
173
+ // managed .env is not versioned per buildId.
174
+ const envContent = readManagedEnvFileContent(effectiveEnv, settings);
175
+ const envFilePath =
176
+ envContent != null ? resolveDockerManagedEnvRemotePath(config, envName) : undefined;
177
+ const cmds = buildRemoteDockerCommands(imageRef, containerName, {}, {
178
+ publishPort: port,
179
+ envFile: envFilePath,
180
+ });
163
181
  const session = sshSession();
164
182
  let ssh = await session.connect();
165
183
  try {
@@ -184,12 +202,40 @@ export function createDockerProvider(config, envName, env = process.env) {
184
202
  );
185
203
  }
186
204
 
205
+ if (envContent != null && envFilePath) {
206
+ await uploadManagedEnvFile({
207
+ ssh,
208
+ session,
209
+ content: envContent,
210
+ remotePath: envFilePath,
211
+ user: sshTarget().user,
212
+ log,
213
+ });
214
+ }
215
+
187
216
  await session.exec(ssh, cmds.stop);
188
217
  await session.exec(ssh, cmds.rm);
189
218
  await session.exec(ssh, cmds.pull, {
190
219
  timeoutMs: Math.max(session.defaultExecTimeoutMs, 300_000),
191
220
  });
192
- await session.exec(ssh, cmds.run);
221
+ // `docker rm -f` can return before the name is fully released on a
222
+ // contended dockerd (`--rm` + stop). Re-rm immediately before run and
223
+ // retry name conflicts instead of failing a good deploy.
224
+ let lastRunErr;
225
+ for (let attempt = 1; attempt <= 3; attempt++) {
226
+ await session.exec(ssh, cmds.rm);
227
+ try {
228
+ await session.exec(ssh, cmds.run);
229
+ lastRunErr = null;
230
+ break;
231
+ } catch (err) {
232
+ lastRunErr = err;
233
+ const msg = err instanceof Error ? err.message : String(err);
234
+ if (!/already in use|Conflict/i.test(msg) || attempt === 3) throw err;
235
+ await new Promise((r) => setTimeout(r, 1000 * attempt));
236
+ }
237
+ }
238
+ if (lastRunErr) throw lastRunErr;
193
239
  const inspected = await session.execUnchecked(
194
240
  ssh,
195
241
  buildDockerInspectPortsCommand(containerName)