@akash-chowdhury-24/deployhub 2.0.0 → 2.0.2

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,432 @@
1
+ import inquirer from 'inquirer';
2
+ import chalk from 'chalk';
3
+ import {
4
+ suggestSshUser,
5
+ listKubeContexts,
6
+ detectKubeconfigPath,
7
+ detectAzureSubscriptionId,
8
+ detectGcpProjectId,
9
+ runSshInitValidation,
10
+ testKubeConnectivity,
11
+ getDeployTypeLabel,
12
+ } from './init-helpers.js';
13
+
14
+ export const SERVER_DEPLOY_TYPES = [
15
+ { name: 'SSH — any Linux server you already have', value: 'ssh' },
16
+ { name: 'Docker — containerized app (local or remote daemon)', value: 'docker' },
17
+ { name: 'AWS EC2 — SSH to an existing EC2 instance', value: 'ec2' },
18
+ { name: 'Azure VM — SSH to an existing Azure virtual machine', value: 'azure-vm' },
19
+ { name: 'GCP VM — SSH to an existing Compute Engine instance', value: 'gcp-vm' },
20
+ { name: 'Kubernetes — deploy to an existing cluster', value: 'kubernetes' },
21
+ ];
22
+
23
+ const SSH_BASED = ['ssh', 'ec2', 'azure-vm', 'gcp-vm'];
24
+
25
+ /**
26
+ * @param {string} projectName
27
+ * @param {'frontend'|'backend'|'both'} projectType
28
+ * @param {Record<string, unknown>|null} backendConfig
29
+ */
30
+ export async function promptServerDeployment(projectName, projectType, backendConfig) {
31
+ const isBackend = projectType === 'backend' || projectType === 'both';
32
+
33
+ const base = await inquirer.prompt([
34
+ {
35
+ type: 'list',
36
+ name: 'deployType',
37
+ message: 'Deployment type:',
38
+ choices: SERVER_DEPLOY_TYPES,
39
+ },
40
+ {
41
+ type: 'input',
42
+ name: 'envName',
43
+ message: 'Environment name (e.g. production, staging):',
44
+ default: 'production',
45
+ },
46
+ ]);
47
+
48
+ const deployType = base.deployType;
49
+
50
+ if (deployType === 'kubernetes') {
51
+ return promptKubernetesDeployment(base, projectName, projectType);
52
+ }
53
+
54
+ if (deployType === 'docker') {
55
+ return promptDockerDeployment(base, projectName, projectType);
56
+ }
57
+
58
+ return promptSshBasedDeployment(base, projectName, projectType, backendConfig, deployType);
59
+ }
60
+
61
+ /**
62
+ * @param {Record<string, string>} base
63
+ * @param {string} projectName
64
+ * @param {'frontend'|'backend'|'both'} projectType
65
+ */
66
+ async function promptKubernetesDeployment(base, projectName, projectType) {
67
+ const defaultKubeconfig = await detectKubeconfigPath();
68
+ const contexts = await listKubeContexts();
69
+
70
+ const kubeAnswers = await inquirer.prompt([
71
+ {
72
+ type: 'input',
73
+ name: 'kubeconfig',
74
+ message: 'Path to kubeconfig file (e.g. ~/.kube/config):',
75
+ default: defaultKubeconfig || '~/.kube/config',
76
+ },
77
+ {
78
+ type: contexts.length > 0 ? 'list' : 'input',
79
+ name: 'kubeContext',
80
+ message: 'Kubernetes context to deploy into:',
81
+ choices: contexts.length > 0 ? contexts : undefined,
82
+ default: contexts[0],
83
+ },
84
+ {
85
+ type: 'input',
86
+ name: 'kubeNamespace',
87
+ message: 'Kubernetes namespace (e.g. my-app or default):',
88
+ default: projectName,
89
+ },
90
+ {
91
+ type: 'input',
92
+ name: 'dockerImageName',
93
+ message: 'Container image name (e.g. ghcr.io/myorg/myapp):',
94
+ default: projectName,
95
+ },
96
+ {
97
+ type: 'input',
98
+ name: 'healthUrl',
99
+ message: 'Health check URL (optional, e.g. https://myapp.example.com/health):',
100
+ },
101
+ ]);
102
+
103
+ console.log(chalk.gray('\n Testing Kubernetes connectivity...'));
104
+ const kubeTest = await testKubeConnectivity(kubeAnswers.kubeconfig, kubeAnswers.kubeContext);
105
+ if (kubeTest.ok) {
106
+ console.log(chalk.green(` ✓ ${kubeTest.message}`));
107
+ } else {
108
+ console.log(chalk.yellow(` ⚠ ${kubeTest.message}`));
109
+ }
110
+
111
+ return {
112
+ ...base,
113
+ kubeconfig: kubeAnswers.kubeconfig,
114
+ kubeContext: kubeAnswers.kubeContext,
115
+ kubeNamespace: kubeAnswers.kubeNamespace,
116
+ dockerImageName: kubeAnswers.dockerImageName,
117
+ healthUrl: kubeAnswers.healthUrl,
118
+ };
119
+ }
120
+
121
+ /**
122
+ * @param {Record<string, string>} base
123
+ * @param {string} projectName
124
+ * @param {'frontend'|'backend'|'both'} projectType
125
+ */
126
+ async function promptDockerDeployment(base, projectName, projectType) {
127
+ const dockerAnswers = await inquirer.prompt([
128
+ {
129
+ type: 'input',
130
+ name: 'dockerImageName',
131
+ message: 'Docker image name (e.g. myorg/myapp or ghcr.io/myorg/myapp):',
132
+ default: projectName,
133
+ },
134
+ {
135
+ type: 'input',
136
+ name: 'dockerRegistryUrl',
137
+ message: 'Registry URL (leave empty for Docker Hub):',
138
+ },
139
+ {
140
+ type: 'input',
141
+ name: 'dockerRegistryUsername',
142
+ message: 'Registry username (only if using a private registry):',
143
+ },
144
+ {
145
+ type: 'password',
146
+ name: 'dockerRegistryToken',
147
+ message: 'Registry token/password (only if using a private registry):',
148
+ },
149
+ {
150
+ type: 'input',
151
+ name: 'dockerHost',
152
+ message: 'Remote Docker host (optional, e.g. ssh://ubuntu@203.0.113.10):',
153
+ },
154
+ {
155
+ type: 'input',
156
+ name: 'healthUrl',
157
+ message: 'Health check URL (optional):',
158
+ },
159
+ ]);
160
+
161
+ return { ...base, ...dockerAnswers };
162
+ }
163
+
164
+ /**
165
+ * @param {Record<string, string>} base
166
+ * @param {string} projectName
167
+ * @param {'frontend'|'backend'|'both'} projectType
168
+ * @param {Record<string, unknown>|null} backendConfig
169
+ * @param {string} deployType
170
+ */
171
+ async function promptSshBasedDeployment(base, projectName, projectType, backendConfig, deployType) {
172
+ const isBackend = projectType === 'backend' || projectType === 'both';
173
+
174
+ let detectedSubscription;
175
+ let detectedProject;
176
+ if (deployType === 'azure-vm') {
177
+ detectedSubscription = await detectAzureSubscriptionId();
178
+ if (detectedSubscription) {
179
+ console.log(chalk.gray(` Detected Azure subscription: ${detectedSubscription}`));
180
+ }
181
+ }
182
+ if (deployType === 'gcp-vm') {
183
+ detectedProject = await detectGcpProjectId();
184
+ if (detectedProject) {
185
+ console.log(chalk.gray(` Detected GCP project: ${detectedProject}`));
186
+ }
187
+ }
188
+
189
+ /** @type {import('inquirer').QuestionCollection} */
190
+ const questions = [
191
+ {
192
+ type: 'input',
193
+ name: 'host',
194
+ message:
195
+ deployType === 'ec2'
196
+ ? 'EC2 public IP or DNS (e.g. 54.123.45.67 or ec2-xx.compute.amazonaws.com):'
197
+ : deployType === 'azure-vm'
198
+ ? 'Azure VM public IP or DNS (e.g. 20.1.2.3):'
199
+ : deployType === 'gcp-vm'
200
+ ? 'GCP VM external IP (e.g. 34.56.78.90):'
201
+ : 'Server IP or hostname (e.g. 203.0.113.10 or myserver.example.com):',
202
+ when: () => true,
203
+ },
204
+ {
205
+ type: 'input',
206
+ name: 'osHint',
207
+ message: 'Server OS image hint (e.g. Ubuntu, Amazon Linux) — used to suggest SSH user:',
208
+ when: () => ['ec2', 'azure-vm', 'gcp-vm', 'ssh'].includes(deployType),
209
+ },
210
+ {
211
+ type: 'input',
212
+ name: 'user',
213
+ message: 'SSH username (e.g. ubuntu for Ubuntu, ec2-user for Amazon Linux):',
214
+ default: (a) => suggestSshUser(a.osHint) || (deployType === 'ec2' ? 'ubuntu' : 'deploy'),
215
+ },
216
+ {
217
+ type: 'input',
218
+ name: 'keyPath',
219
+ message: 'Path to SSH private key file (e.g. ~/.ssh/my-key.pem):',
220
+ },
221
+ {
222
+ type: 'input',
223
+ name: 'sshPort',
224
+ message: 'SSH port (default 22):',
225
+ default: '22',
226
+ },
227
+ ];
228
+
229
+ if (deployType === 'ec2') {
230
+ questions.push(
231
+ {
232
+ type: 'input',
233
+ name: 'ec2InstanceId',
234
+ message: 'EC2 instance ID for auto IP lookup (optional, e.g. i-0abc123def4567890):',
235
+ },
236
+ {
237
+ type: 'input',
238
+ name: 'awsRegion',
239
+ message: 'AWS region (e.g. us-east-1):',
240
+ default: 'us-east-1',
241
+ when: (a) => !!a.ec2InstanceId,
242
+ }
243
+ );
244
+ }
245
+
246
+ if (deployType === 'azure-vm') {
247
+ questions.push(
248
+ {
249
+ type: 'input',
250
+ name: 'azureSubscriptionId',
251
+ message: 'Azure subscription ID (optional, for auto IP lookup):',
252
+ default: detectedSubscription || '',
253
+ },
254
+ {
255
+ type: 'input',
256
+ name: 'azureResourceGroup',
257
+ message: 'Azure resource group name (optional, e.g. my-app-rg):',
258
+ },
259
+ {
260
+ type: 'input',
261
+ name: 'azureVmName',
262
+ message: 'Azure VM name (optional, for auto IP lookup):',
263
+ }
264
+ );
265
+ }
266
+
267
+ if (deployType === 'gcp-vm') {
268
+ questions.push(
269
+ {
270
+ type: 'input',
271
+ name: 'gcpProjectId',
272
+ message: 'GCP project ID (optional, for auto IP lookup):',
273
+ default: detectedProject || '',
274
+ },
275
+ {
276
+ type: 'input',
277
+ name: 'gcpZone',
278
+ message: 'GCP zone (optional, e.g. us-central1-a):',
279
+ },
280
+ {
281
+ type: 'input',
282
+ name: 'gcpInstanceName',
283
+ message: 'GCP instance name (optional, for auto IP lookup):',
284
+ }
285
+ );
286
+ }
287
+
288
+ if (projectType !== 'both') {
289
+ questions.push({
290
+ type: 'input',
291
+ name: 'deployPath',
292
+ message: `Remote deploy directory (e.g. /var/www/${projectName}):`,
293
+ default: `/var/www/${projectName}`,
294
+ });
295
+ }
296
+
297
+ if (projectType === 'both') {
298
+ questions.push(
299
+ {
300
+ type: 'input',
301
+ name: 'frontendDeployPath',
302
+ message: `Frontend deploy path (e.g. /var/www/${projectName}/public):`,
303
+ default: `/var/www/${projectName}/public`,
304
+ },
305
+ {
306
+ type: 'input',
307
+ name: 'backendDeployPath',
308
+ message: `Backend deploy path (e.g. /var/www/${projectName}/api):`,
309
+ default: `/var/www/${projectName}/api`,
310
+ }
311
+ );
312
+ }
313
+
314
+ if (isBackend) {
315
+ questions.push({
316
+ type: 'input',
317
+ name: 'appName',
318
+ message: `PM2 process name for your backend (e.g. ${projectName}-api):`,
319
+ default: projectType === 'both' ? `${projectName}-api` : projectName,
320
+ });
321
+ }
322
+
323
+ questions.push({
324
+ type: 'input',
325
+ name: 'healthUrl',
326
+ message: 'Health check URL (optional, e.g. https://api.example.com/health):',
327
+ });
328
+
329
+ const sshAnswers = await inquirer.prompt(questions);
330
+
331
+ await runSshInitValidation({
332
+ host: sshAnswers.host,
333
+ user: sshAnswers.user,
334
+ keyPath: sshAnswers.keyPath,
335
+ sshPort: Number(sshAnswers.sshPort) || 22,
336
+ deployType: getDeployTypeLabel(deployType),
337
+ });
338
+
339
+ return { ...base, ...sshAnswers };
340
+ }
341
+
342
+ /**
343
+ * @param {Awaited<ReturnType<typeof promptServerDeployment>>} deployAnswers
344
+ * @param {'frontend'|'backend'|'both'} projectType
345
+ * @param {string} projectName
346
+ * @param {Record<string, unknown>|null} backendConfig
347
+ * @param {Record<string, unknown>|null} singleConfig
348
+ */
349
+ export function buildServerEnvEntry(
350
+ deployAnswers,
351
+ projectType,
352
+ projectName,
353
+ backendConfig,
354
+ singleConfig
355
+ ) {
356
+ /** @type {Record<string, unknown>} */
357
+ const envEntry = {
358
+ deploymentType: 'server',
359
+ type: deployAnswers.deployType,
360
+ };
361
+
362
+ if (deployAnswers.deployType === 'kubernetes') {
363
+ envEntry.kubeconfig = deployAnswers.kubeconfig;
364
+ envEntry.kubeContext = deployAnswers.kubeContext;
365
+ envEntry.kubeNamespace = deployAnswers.kubeNamespace || projectName;
366
+ envEntry.dockerImageName = deployAnswers.dockerImageName || projectName;
367
+ return envEntry;
368
+ }
369
+
370
+ if (deployAnswers.deployType === 'docker') {
371
+ envEntry.dockerImageName = deployAnswers.dockerImageName || projectName;
372
+ envEntry.dockerRegistryUrl = deployAnswers.dockerRegistryUrl || '';
373
+ envEntry.dockerHost = deployAnswers.dockerHost || '';
374
+ return envEntry;
375
+ }
376
+
377
+ envEntry.host = deployAnswers.host || '';
378
+ envEntry.user = deployAnswers.user || '';
379
+ envEntry.keyPath = deployAnswers.keyPath || '';
380
+ envEntry.sshPort = Number(deployAnswers.sshPort) || 22;
381
+
382
+ if (deployAnswers.ec2InstanceId) envEntry.ec2InstanceId = deployAnswers.ec2InstanceId;
383
+ if (deployAnswers.awsRegion) envEntry.awsRegion = deployAnswers.awsRegion;
384
+ if (deployAnswers.azureSubscriptionId) envEntry.azureSubscriptionId = deployAnswers.azureSubscriptionId;
385
+ if (deployAnswers.azureResourceGroup) envEntry.azureResourceGroup = deployAnswers.azureResourceGroup;
386
+ if (deployAnswers.azureVmName) envEntry.azureVmName = deployAnswers.azureVmName;
387
+ if (deployAnswers.gcpProjectId) envEntry.gcpProjectId = deployAnswers.gcpProjectId;
388
+ if (deployAnswers.gcpZone) envEntry.gcpZone = deployAnswers.gcpZone;
389
+ if (deployAnswers.gcpInstanceName) envEntry.gcpInstanceName = deployAnswers.gcpInstanceName;
390
+
391
+ if (projectType === 'both') {
392
+ envEntry.frontendDeployPath =
393
+ deployAnswers.frontendDeployPath || `/var/www/${projectName}/public`;
394
+ envEntry.backendDeployPath =
395
+ deployAnswers.backendDeployPath || `/var/www/${projectName}/api`;
396
+ envEntry.appName = deployAnswers.appName || `${projectName}-api`;
397
+ envEntry.framework = backendConfig?.framework || 'express';
398
+ envEntry.path = envEntry.backendDeployPath;
399
+ envEntry.backendDeploymentType = 'server';
400
+ } else if (projectType === 'backend') {
401
+ envEntry.deployPath = deployAnswers.deployPath || `/var/www/${projectName}`;
402
+ envEntry.path = envEntry.deployPath;
403
+ envEntry.appName = deployAnswers.appName || projectName;
404
+ envEntry.framework = singleConfig?.framework || 'express';
405
+ envEntry.port = singleConfig?.port || 3000;
406
+ } else {
407
+ envEntry.deployPath = deployAnswers.deployPath || `/var/www/${projectName}`;
408
+ envEntry.path = envEntry.deployPath;
409
+ }
410
+
411
+ return envEntry;
412
+ }
413
+
414
+ /**
415
+ * @param {Awaited<ReturnType<typeof promptServerDeployment>>} deployAnswers
416
+ * @returns {Record<string, string>|null}
417
+ */
418
+ export function getDockerEnvSecrets(deployAnswers) {
419
+ if (deployAnswers.deployType !== 'docker') return null;
420
+
421
+ /** @type {Record<string, string>} */
422
+ const vars = {};
423
+ if (deployAnswers.dockerRegistryUsername) {
424
+ vars.DOCKER_REGISTRY_USERNAME = deployAnswers.dockerRegistryUsername;
425
+ }
426
+ if (deployAnswers.dockerRegistryToken) {
427
+ vars.DOCKER_REGISTRY_TOKEN = deployAnswers.dockerRegistryToken;
428
+ }
429
+ return Object.keys(vars).length > 0 ? vars : null;
430
+ }
431
+
432
+ export { SSH_BASED };
@@ -1,7 +1,90 @@
1
+ import { execa } from 'execa';
1
2
  import { createSshProvider } from './ssh.js';
3
+ import { createLogger } from '../../logger/index.js';
2
4
 
3
- export function createAzureVmProvider(config, envName, env) {
4
- return createSshProvider(config, envName, env);
5
+ /**
6
+ * @param {import('../../core/config.js').DeployHubConfig} config
7
+ * @param {string} envName
8
+ * @param {Record<string, string>} [env]
9
+ */
10
+ export function createAzureVmProvider(config, envName, env = process.env) {
11
+ const log = createLogger('azure-vm');
12
+ const subscriptionId = env.AZURE_SUBSCRIPTION_ID;
13
+ const resourceGroup = env.AZURE_RESOURCE_GROUP;
14
+ const vmName = env.AZURE_VM_NAME;
15
+
16
+ async function resolveHost() {
17
+ const environment = config.environments[envName];
18
+ if (environment?.host || env.SSH_HOST) {
19
+ return environment?.host || env.SSH_HOST;
20
+ }
21
+
22
+ if (!subscriptionId || !resourceGroup || !vmName) {
23
+ throw new Error(
24
+ 'Azure VM host unknown. Set SSH_HOST to your VM public IP, or set AZURE_SUBSCRIPTION_ID, AZURE_RESOURCE_GROUP, and AZURE_VM_NAME for auto lookup.'
25
+ );
26
+ }
27
+
28
+ log.info(`Looking up public IP for Azure VM ${vmName}...`);
29
+ try {
30
+ const { stdout } = await execa(
31
+ 'az',
32
+ [
33
+ 'vm',
34
+ 'show',
35
+ '-d',
36
+ '-g',
37
+ resourceGroup,
38
+ '-n',
39
+ vmName,
40
+ '--subscription',
41
+ subscriptionId,
42
+ '--query',
43
+ 'publicIps',
44
+ '-o',
45
+ 'tsv',
46
+ ],
47
+ { stdio: 'pipe' }
48
+ );
49
+ const publicIp = stdout.trim();
50
+ if (!publicIp) {
51
+ throw new Error('No public IP returned');
52
+ }
53
+ log.info(`Resolved Azure VM host: ${publicIp}`);
54
+ return publicIp;
55
+ } catch (err) {
56
+ const msg = err instanceof Error ? err.message : String(err);
57
+ throw new Error(
58
+ `Could not resolve public IP for VM ${vmName} — ${msg}. Set SSH_HOST manually or run az login and verify resource group/VM name.`
59
+ );
60
+ }
61
+ }
62
+
63
+ const sshProvider = createSshProvider(config, envName, env);
64
+
65
+ async function connect() {
66
+ const host = await resolveHost();
67
+ const environment = config.environments[envName];
68
+ if (environment && !environment.host) {
69
+ environment.host = host;
70
+ }
71
+ if (!env.SSH_HOST) {
72
+ env.SSH_HOST = host;
73
+ }
74
+ return sshProvider.connect();
75
+ }
76
+
77
+ return {
78
+ ...sshProvider,
79
+ connect,
80
+ deploy: sshProvider.deploy.bind(sshProvider),
81
+ rollback: sshProvider.rollback.bind(sshProvider),
82
+ healthCheck: sshProvider.healthCheck.bind(sshProvider),
83
+ testConnection: async () => {
84
+ const ssh = await connect();
85
+ ssh.dispose();
86
+ },
87
+ };
5
88
  }
6
89
 
7
90
  export default { createAzureVmProvider };
@@ -1,27 +1,120 @@
1
1
  import { execa } from 'execa';
2
+ import fs from 'fs-extra';
3
+ import path from 'path';
2
4
  import { createLogger } from '../../logger/index.js';
3
5
 
4
- export function createDockerProvider(config, envName) {
6
+ /**
7
+ * @param {import('../../core/config.js').DeployHubConfig} config
8
+ * @param {string} envName
9
+ * @param {Record<string, string>} [env]
10
+ */
11
+ export function createDockerProvider(config, envName, env = process.env) {
5
12
  const log = createLogger('docker');
6
13
 
14
+ const imageName = env.DOCKER_IMAGE_NAME || config.project;
15
+ const imageTag = env.DOCKER_IMAGE_TAG || config.version || 'latest';
16
+ const registryUrl = env.DOCKER_REGISTRY_URL || '';
17
+ const registryUser = env.DOCKER_REGISTRY_USERNAME || '';
18
+ const registryToken = env.DOCKER_REGISTRY_TOKEN || '';
19
+ const dockerHost = env.DOCKER_HOST || '';
20
+
21
+ function getDockerEnv() {
22
+ /** @type {Record<string, string>} */
23
+ const dockerEnv = { ...process.env };
24
+ if (dockerHost) dockerEnv.DOCKER_HOST = dockerHost;
25
+ if (env.DOCKER_TLS_VERIFY) dockerEnv.DOCKER_TLS_VERIFY = env.DOCKER_TLS_VERIFY;
26
+ if (env.DOCKER_CERT_PATH) dockerEnv.DOCKER_CERT_PATH = env.DOCKER_CERT_PATH;
27
+ return dockerEnv;
28
+ }
29
+
30
+ const fullImage = registryUrl && !imageName.includes('/')
31
+ ? `${registryUrl.replace(/\/$/, '')}/${imageName}:${imageTag}`
32
+ : `${imageName}:${imageTag}`;
33
+
34
+ async function dockerLogin() {
35
+ if (!registryUser || !registryToken) return;
36
+ const registry = registryUrl || 'https://index.docker.io/v1/';
37
+ log.info('Logging in to container registry...');
38
+ await execa(
39
+ 'docker',
40
+ ['login', registry, '-u', registryUser, '--password-stdin'],
41
+ {
42
+ input: registryToken,
43
+ stdio: ['pipe', 'inherit', 'inherit'],
44
+ env: getDockerEnv(),
45
+ }
46
+ );
47
+ }
48
+
49
+ /**
50
+ * @param {string} artifactDir
51
+ */
7
52
  async function deploy(artifactDir) {
8
- log.info('Deploying via Docker...');
9
- await execa('docker', ['compose', 'up', '-d', '--build'], {
10
- cwd: artifactDir,
11
- stdio: 'inherit',
12
- });
53
+ log.info(`Deploying via Docker (image: ${fullImage})...`);
54
+ const dockerEnv = getDockerEnv();
55
+
56
+ const composePath = path.join(artifactDir, 'docker-compose.yml');
57
+ const dockerfilePath = path.join(artifactDir, 'Dockerfile');
58
+
59
+ const hasCompose = await fs.pathExists(composePath);
60
+ const hasDockerfile = await fs.pathExists(dockerfilePath);
61
+
62
+ await dockerLogin();
63
+
64
+ if (hasCompose) {
65
+ await execa(
66
+ 'docker',
67
+ ['compose', 'up', '-d', '--build'],
68
+ { cwd: artifactDir, stdio: 'inherit', env: dockerEnv }
69
+ );
70
+ } else if (hasDockerfile) {
71
+ await execa('docker', ['build', '-t', fullImage, '.'], {
72
+ cwd: artifactDir,
73
+ stdio: 'inherit',
74
+ env: dockerEnv,
75
+ });
76
+ await execa('docker', ['push', fullImage], {
77
+ stdio: 'inherit',
78
+ env: dockerEnv,
79
+ }).catch(() => {
80
+ log.warn('docker push skipped (registry may be local or push not configured)');
81
+ });
82
+ await execa('docker', ['run', '-d', '--rm', '--name', config.project, fullImage], {
83
+ stdio: 'inherit',
84
+ env: dockerEnv,
85
+ });
86
+ } else {
87
+ throw new Error(
88
+ 'No Dockerfile or docker-compose.yml found in artifact. Add one to your project or enable Docker in pipeline config.'
89
+ );
90
+ }
91
+
92
+ log.success('Docker deployment complete');
13
93
  }
14
94
 
15
95
  async function rollback(artifactDir) {
96
+ log.info('Rolling back Docker deployment (redeploy previous artifact)...');
16
97
  await deploy(artifactDir);
17
98
  }
18
99
 
19
100
  async function healthCheck() {
20
- return true;
101
+ const url = config.healthCheck?.url;
102
+ if (!url) return true;
103
+
104
+ try {
105
+ const { stdout } = await execa(
106
+ 'docker',
107
+ ['ps', '--filter', `name=${config.project}`, '--format', '{{.Status}}'],
108
+ { stdio: 'pipe', env: getDockerEnv() }
109
+ );
110
+ return stdout.includes('Up');
111
+ } catch {
112
+ return false;
113
+ }
21
114
  }
22
115
 
23
116
  async function testConnection() {
24
- await execa('docker', ['info'], { stdio: 'pipe' });
117
+ await execa('docker', ['info'], { stdio: 'pipe', env: getDockerEnv() });
25
118
  }
26
119
 
27
120
  return { deploy, rollback, healthCheck, testConnection };