@akash-chowdhury-24/deployhub 2.0.11 → 2.0.14

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 CHANGED
@@ -225,10 +225,12 @@ Or push to `main` / `master` — the generated workflow runs the same command.
225
225
  **Useful follow-up commands:**
226
226
 
227
227
  ```bash
228
- deployhub artifact list # see uploaded versions
229
- deployhub artifact restore v1.2.3 # download a past build
228
+ deployhub artifact list # local artifacts
229
+ deployhub artifact list --remote # include storage history.json
230
+ deployhub artifact restore <buildId> # download a past build
230
231
  deployhub deploy # deploy latest artifact without rebuilding
231
- deployhub rollback v1.2.2 # roll back on server
232
+ deployhub rollback # previous build from history
233
+ deployhub rollback <buildId> # exact build (required if semver is ambiguous)
232
234
  deployhub logs # last deployment logs
233
235
  ```
234
236
 
@@ -269,7 +271,17 @@ deployhub doctor
269
271
  deployhub build
270
272
  ```
271
273
 
272
- Artifacts appear under `artifact/{projectName}/{date}/v{version}/` locally **and** in your S3 bucket.
274
+ Artifacts appear under `artifact/{projectName}/{date}/v{buildId}/` locally.
275
+
276
+ **Remote storage** (S3 and other providers) uses a different layout:
277
+
278
+ ```text
279
+ {project}/builds/{buildId}/artifact.zip # immutable per CI/build
280
+ {project}/history.json # newest-first index for rollback / list --remote
281
+ {project}/latest/artifact.zip # mutable pointer overwritten every upload (NOT a backup)
282
+ ```
283
+
284
+ `buildId` is unique per pipeline run (e.g. `1.0.6-a1b2c3d`) even if `package.json` semver is unchanged. Legacy keys `{project}/v{semver}/artifact.zip` are no longer written; they remain readable for older uploads only.
273
285
 
274
286
  ### Same steps for other languages
275
287
 
@@ -943,12 +955,12 @@ Run `deployhub doctor` after any config change.
943
955
  | `deployhub init` | Interactive project setup |
944
956
  | `deployhub build` | Full pipeline: detect → install → test → build → artifact → storage → deploy |
945
957
  | `deployhub artifact create` | Create artifact from current build |
946
- | `deployhub artifact list` | List all artifacts |
947
- | `deployhub artifact restore <version>` | Download and extract an artifact |
958
+ | `deployhub artifact list [--remote]` | List local artifacts; `--remote` merges storage `history.json` |
959
+ | `deployhub artifact restore <buildId\|semver>` | Download and extract an artifact |
948
960
  | `deployhub storage add <provider>` | Add storage provider credentials |
949
961
  | `deployhub storage list` | List storage providers and connection status |
950
962
  | `deployhub deploy` | Deploy latest artifact |
951
- | `deployhub rollback [version]` | Rollback to a previous version |
963
+ | `deployhub rollback [buildId\|semver]` | Previous build, or exact buildId (ambiguous semver lists matches and exits) |
952
964
  | `deployhub logs` | Show logs from last deployment |
953
965
  | `deployhub doctor` | Pre-flight checks |
954
966
  | `deployhub verify` | Health check on configured endpoint |
@@ -1029,13 +1041,13 @@ If checks fail:
1029
1041
 
1030
1042
  ## Artifact Structure
1031
1043
 
1032
- Each build creates:
1044
+ Each build creates a **local** directory:
1033
1045
 
1034
1046
  ```
1035
1047
  artifact/
1036
1048
  {projectName}/
1037
1049
  {YYYY-MM-DD}/
1038
- v{semver}/
1050
+ v{buildId}/
1039
1051
  artifact.zip
1040
1052
  metadata.json
1041
1053
  logs.txt
@@ -1045,6 +1057,18 @@ artifact/
1045
1057
  README.md
1046
1058
  ```
1047
1059
 
1060
+ `buildId` looks like `{semver}-{gitSha|ciId|timestamp}` (unique every pipeline run).
1061
+
1062
+ **Remote keys** (all storage providers):
1063
+
1064
+ ```
1065
+ {project}/builds/{buildId}/artifact.zip
1066
+ {project}/history.json
1067
+ {project}/latest/artifact.zip # overwritten every build — convenience pointer only, not version history
1068
+ ```
1069
+
1070
+ Legacy (read-only fallback, no longer written): `{project}/v{semver}/artifact.zip`
1071
+
1048
1072
  `deployment.json` records server deployment metadata per environment:
1049
1073
 
1050
1074
  ```json
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akash-chowdhury-24/deployhub",
3
- "version": "2.0.11",
3
+ "version": "2.0.14",
4
4
  "description": "Zero-configuration deployment and artifact manager",
5
5
  "type": "module",
6
6
  "main": "./src/cli/index.js",
@@ -5,6 +5,7 @@ import { execa } from 'execa';
5
5
  import { createLogger } from '../logger/index.js';
6
6
  import { generateChecksums, formatChecksums } from '../utils/checksums.js';
7
7
  import { getProjectVersion } from '../utils/version.js';
8
+ import { resolveBuildId } from '../utils/build-id.js';
8
9
  import { generateNginxConfig } from '../utils/nginx.js';
9
10
  import {
10
11
  ensureDeployScaffold,
@@ -238,12 +239,13 @@ async function stageBackendArtifact(cwd, stagingDir, config) {
238
239
  */
239
240
  export function getArtifactDir(config, cwd = process.cwd()) {
240
241
  const date = new Date().toISOString().slice(0, 10);
242
+ const buildId = config.buildId || config.version || '0.0.0';
241
243
  return path.join(
242
244
  cwd,
243
245
  'artifact',
244
246
  config.project,
245
247
  date,
246
- `v${config.version}`
248
+ `v${buildId}`
247
249
  );
248
250
  }
249
251
 
@@ -257,6 +259,10 @@ export async function createArtifact(config, deployedTargets = [], cwd = process
257
259
  const log = createLogger('artifact');
258
260
  const version = config.version || (await getProjectVersion(cwd));
259
261
  config.version = version;
262
+ if (!config.buildId) {
263
+ const { buildId } = resolveBuildId({ semver: version });
264
+ config.buildId = buildId;
265
+ }
260
266
 
261
267
  const artifactDir = getArtifactDir(config, cwd);
262
268
  const stagingDir = path.join(artifactDir, '_staging');
@@ -266,7 +272,7 @@ export async function createArtifact(config, deployedTargets = [], cwd = process
266
272
  const artifactType = resolveArtifactType(config);
267
273
  const settings = resolveBuildSettings(config);
268
274
 
269
- log.info(`Staging ${projectType} artifact...`);
275
+ log.info(`Staging ${projectType} artifact (buildId=${config.buildId})...`);
270
276
 
271
277
  await ensureDeployScaffold(cwd, config, config.environments || {}, { silent: false });
272
278
 
@@ -288,6 +294,7 @@ export async function createArtifact(config, deployedTargets = [], cwd = process
288
294
  const metadata = {
289
295
  project: config.project,
290
296
  version,
297
+ buildId: config.buildId,
291
298
  timestamp,
292
299
  gitCommit: git.commit,
293
300
  branch: git.branch,
package/src/cli/index.js CHANGED
@@ -13,6 +13,7 @@ import { registerDoctorCommand } from '../commands/doctor.js';
13
13
  import { registerVerifyCommand } from '../commands/verify.js';
14
14
  import { registerCleanCommand } from '../commands/clean.js';
15
15
  import { registerUpdateCommand } from '../commands/update.js';
16
+ import { registerSyncWorkflowsCommand } from '../commands/sync-workflows.js';
16
17
  import { formatVersionOutput, printBanner, shouldShowBanner } from '../utils/author.js';
17
18
 
18
19
  loadEnv();
@@ -41,5 +42,6 @@ registerDoctorCommand(program);
41
42
  registerVerifyCommand(program);
42
43
  registerCleanCommand(program);
43
44
  registerUpdateCommand(program);
45
+ registerSyncWorkflowsCommand(program);
44
46
 
45
47
  program.parse();
@@ -5,7 +5,10 @@ import {
5
5
  listLocalArtifacts,
6
6
  extractArtifact,
7
7
  } from '../artifact/engine.js';
8
- import { downloadFromFirst } from '../storage/index.js';
8
+ import { downloadArtifactEntry, loadArtifactHistory, downloadFromFirst } from '../storage/index.js';
9
+ import {
10
+ legacyArtifactRemoteKey,
11
+ } from '../utils/build-id.js';
9
12
  import fs from 'fs-extra';
10
13
  import path from 'path';
11
14
 
@@ -29,53 +32,105 @@ export function registerArtifactCommand(program) {
29
32
 
30
33
  artifact
31
34
  .command('list')
32
- .description('List all artifacts')
33
- .action(async () => {
35
+ .description('List local artifacts (add --remote to include storage history.json)')
36
+ .option('--remote', 'Also list builds from remote history.json')
37
+ .action(async (opts) => {
34
38
  loadEnv();
39
+ const config = await loadConfig();
35
40
  const artifacts = await listLocalArtifacts();
36
41
 
42
+ console.log(chalk.bold('\nLocal artifacts:\n'));
37
43
  if (artifacts.length === 0) {
38
- console.log(chalk.yellow('No local artifacts found.'));
39
- return;
44
+ console.log(chalk.yellow(' (none)'));
45
+ } else {
46
+ for (const a of artifacts) {
47
+ const sizeMb = (a.size / 1024 / 1024).toFixed(2);
48
+ console.log(
49
+ ` ${chalk.cyan(a.version)} ${a.date} ${a.project} ${sizeMb} MB`
50
+ );
51
+ console.log(chalk.gray(` ${a.path}`));
52
+ }
40
53
  }
41
54
 
42
- console.log(chalk.bold('\nArtifacts:\n'));
43
- for (const a of artifacts) {
44
- const sizeMb = (a.size / 1024 / 1024).toFixed(2);
45
- console.log(
46
- ` ${chalk.cyan(a.version)} ${a.date} ${a.project} ${sizeMb} MB`
47
- );
48
- console.log(chalk.gray(` ${a.path}`));
55
+ if (opts.remote) {
56
+ console.log(chalk.bold('\nRemote history (storage):\n'));
57
+ try {
58
+ const { entries: history, source } = await loadArtifactHistory(
59
+ config.storage || [],
60
+ config.project
61
+ );
62
+ if (history.length === 0) {
63
+ console.log(
64
+ chalk.yellow(
65
+ ' No artifact history found for this project — you may not have deployed any builds yet.'
66
+ )
67
+ );
68
+ } else {
69
+ if (source) {
70
+ console.log(chalk.gray(` Source: ${source}`));
71
+ console.log('');
72
+ }
73
+ for (const e of history) {
74
+ console.log(
75
+ ` ${chalk.cyan(e.buildId)} semver=${e.semver} ${e.uploadedAt || ''}`
76
+ );
77
+ console.log(chalk.gray(` ${e.remoteKey}`));
78
+ }
79
+ }
80
+ } catch (err) {
81
+ const detail = err instanceof Error ? err.message : String(err);
82
+ console.log(chalk.red(` ${detail}`));
83
+ }
49
84
  }
85
+
50
86
  console.log('');
51
87
  });
52
88
 
53
89
  artifact
54
- .command('restore <version>')
55
- .description('Download and extract an artifact by version')
56
- .action(async (version) => {
90
+ .command('restore <versionOrBuildId>')
91
+ .description('Download and extract an artifact by buildId or legacy semver')
92
+ .action(async (versionOrBuildId) => {
57
93
  loadEnv();
58
94
  const config = await loadConfig();
59
95
  const cwd = process.cwd();
96
+ const needle = String(versionOrBuildId).replace(/^v/i, '');
60
97
 
61
98
  const local = await listLocalArtifacts(cwd);
62
- const localMatch = local.find((a) => a.version === version);
99
+ const localMatch = local.find(
100
+ (a) => a.version === needle || a.version === versionOrBuildId
101
+ );
63
102
 
64
103
  if (localMatch) {
65
- const extractTo = path.join(cwd, '.deployhub-restore', `v${version}`);
104
+ const extractTo = path.join(cwd, '.deployhub-restore', `v${localMatch.version}`);
66
105
  await extractArtifact(localMatch.path, extractTo);
67
106
  console.log(chalk.green(`Restored to ${extractTo}`));
68
107
  return;
69
108
  }
70
109
 
71
- const remoteKey = `${config.project}/v${version}/artifact.zip`;
72
- const restoreDir = path.join(cwd, '.deployhub-restore', `v${version}`);
110
+ const history = await loadArtifactHistory(config.storage || [], config.project);
111
+ const histMatch =
112
+ history.entries.find((e) => e.buildId === needle || e.buildId === versionOrBuildId) ||
113
+ history.entries.find((e) => e.semver === needle);
114
+
115
+ const restoreDir = path.join(cwd, '.deployhub-restore', `v${needle}`);
73
116
  await fs.ensureDir(restoreDir);
74
117
  const zipPath = path.join(restoreDir, 'artifact.zip');
75
118
 
76
- console.log(`Downloading v${version} from storage...`);
77
- const provider = await downloadFromFirst(config.storage, remoteKey, zipPath);
78
- console.log(chalk.gray(`Downloaded from ${provider}`));
119
+ if (histMatch) {
120
+ console.log(`Downloading ${histMatch.buildId} from storage...`);
121
+ const provider = await downloadArtifactEntry(
122
+ config.storage,
123
+ config,
124
+ histMatch,
125
+ zipPath
126
+ );
127
+ console.log(chalk.gray(`Downloaded from ${provider}`));
128
+ } else {
129
+ const remoteKey = legacyArtifactRemoteKey(config.project, needle);
130
+ console.log(`Downloading legacy key ${remoteKey} from storage...`);
131
+ const provider = await downloadFromFirst(config.storage, remoteKey, zipPath);
132
+ console.log(chalk.gray(`Downloaded from ${provider}`));
133
+ }
79
134
 
80
135
  const versionDir = path.join(restoreDir, 'artifact');
81
136
  await fs.ensureDir(versionDir);
@@ -7,7 +7,7 @@ import axios from 'axios';
7
7
  import { loadConfig, loadEnv } from '../core/config.js';
8
8
  import { testProvider } from '../storage/index.js';
9
9
  import { getDeploymentProvider } from '../deployment/index.js';
10
- import { PROVIDER_ENV_MAP } from '../utils/github-actions.js';
10
+ import { PROVIDER_ENV_MAP, getRollbackWorkflowDoctorCheck } from '../utils/github-actions.js';
11
11
  import { printDoctorFooter } from '../utils/author.js';
12
12
  import { createLocalProvider } from '../storage/providers/local.js';
13
13
  import {
@@ -888,11 +888,28 @@ export function registerDoctorCommand(program) {
888
888
  return {
889
889
  name: 'GitHub Actions',
890
890
  pass: false,
891
- message: 'Workflow file missing — run deployhub init',
891
+ message: 'Workflow file missing — run deployhub init or deployhub sync-workflows',
892
892
  };
893
893
  })
894
894
  );
895
895
 
896
+ const hasStorage = (config.storage || []).length > 0;
897
+ const hasDeploy = (config.deploy || []).length > 0;
898
+ if (hasStorage && hasDeploy) {
899
+ results.push(
900
+ await runCheck('Rollback workflow', async () => {
901
+ const check = await getRollbackWorkflowDoctorCheck(cwd, config);
902
+ return (
903
+ check || {
904
+ name: 'Rollback workflow',
905
+ pass: true,
906
+ message: 'Skipped',
907
+ }
908
+ );
909
+ })
910
+ );
911
+ }
912
+
896
913
  results.push(
897
914
  await runCheck('Storage write', async () => {
898
915
  const provider = createLocalProvider();
@@ -535,6 +535,7 @@ export function registerInitCommand(program) {
535
535
  console.log(chalk.bold('Generated files:'));
536
536
  console.log(' • deployhub.config.json');
537
537
  console.log(' • .github/workflows/deployhub.yml');
538
+ console.log(' • .github/workflows/deployhub-rollback.yml');
538
539
  console.log(' • .env.example');
539
540
  console.log('');
540
541
  printAuthorFooter();
@@ -8,42 +8,45 @@ import axios from 'axios';
8
8
  */
9
9
  export function registerRollbackCommand(program) {
10
10
  program
11
- .command('rollback [version]')
12
- .description('Rollback to a previous artifact version')
13
- .action(async (version) => {
11
+ .command('rollback [versionOrBuildId]')
12
+ .description(
13
+ 'Rollback to a previous artifact build (omit arg = previous build; use exact buildId if semver is ambiguous)'
14
+ )
15
+ .action(async (versionOrBuildId) => {
14
16
  loadEnv();
15
17
  const config = await loadConfig();
16
18
 
17
- if (!version) {
18
- const { listLocalArtifacts } = await import('../artifact/engine.js');
19
- const artifacts = await listLocalArtifacts();
20
- if (artifacts.length < 2) {
21
- console.error(chalk.red('No previous version available for rollback'));
22
- process.exit(1);
19
+ try {
20
+ const { entry } = await rollbackToVersion(config, versionOrBuildId);
21
+ if (!versionOrBuildId) {
22
+ console.log(chalk.gray(`Rolled back to previous build: ${entry.buildId}`));
23
23
  }
24
- version = artifacts[1].version;
25
- console.log(chalk.gray(`Rolling back to previous version: v${version}`));
26
- }
27
-
28
- await rollbackToVersion(config, version);
29
24
 
30
- if (config.healthCheck?.url) {
31
- try {
32
- const response = await axios.get(config.healthCheck.url, {
33
- timeout: (config.healthCheck.timeout || 30) * 1000,
34
- validateStatus: () => true,
35
- });
36
- if (response.status >= 200 && response.status < 400) {
37
- console.log(chalk.green(`Health check passed: HTTP ${response.status}`));
38
- } else {
39
- console.log(chalk.yellow(`Health check returned HTTP ${response.status}`));
25
+ if (config.healthCheck?.url) {
26
+ try {
27
+ const response = await axios.get(config.healthCheck.url, {
28
+ timeout: (config.healthCheck.timeout || 30) * 1000,
29
+ validateStatus: () => true,
30
+ });
31
+ if (response.status >= 200 && response.status < 400) {
32
+ console.log(chalk.green(`Health check passed: HTTP ${response.status}`));
33
+ } else {
34
+ console.log(chalk.yellow(`Health check returned HTTP ${response.status}`));
35
+ }
36
+ } catch (err) {
37
+ console.log(
38
+ chalk.yellow(
39
+ `Health check failed: ${err instanceof Error ? err.message : String(err)}`
40
+ )
41
+ );
40
42
  }
41
- } catch (err) {
42
- console.log(chalk.yellow(`Health check failed: ${err instanceof Error ? err.message : String(err)}`));
43
43
  }
44
- }
45
44
 
46
- console.log(chalk.green(`✓ Rolled back to v${version}`));
45
+ console.log(chalk.green(`✓ Rolled back to ${entry.buildId}`));
46
+ } catch (err) {
47
+ console.error(chalk.red(err instanceof Error ? err.message : String(err)));
48
+ process.exit(1);
49
+ }
47
50
  });
48
51
  }
49
52
 
@@ -0,0 +1,43 @@
1
+ import chalk from 'chalk';
2
+ import { loadConfig, loadEnv } from '../core/config.js';
3
+ import {
4
+ writeWorkflowFile,
5
+ DEPLOY_WORKFLOW_FILENAME,
6
+ ROLLBACK_WORKFLOW_FILENAME,
7
+ } from '../utils/github-actions.js';
8
+
9
+ /**
10
+ * Regenerate GitHub Actions workflows from deployhub.config.json (no interactive init).
11
+ * @param {import('commander').Command} program
12
+ */
13
+ export function registerSyncWorkflowsCommand(program) {
14
+ program
15
+ .command('sync-workflows')
16
+ .description(
17
+ 'Regenerate .github/workflows/deployhub.yml and deployhub-rollback.yml from deployhub.config.json'
18
+ )
19
+ .action(async () => {
20
+ loadEnv();
21
+ const cwd = process.cwd();
22
+ const config = await loadConfig(cwd);
23
+
24
+ const storage = config.storage || [];
25
+ const deploy = config.deploy || [];
26
+ const environments = config.environments || {};
27
+ const cliSource = config.cli?.source;
28
+
29
+ await writeWorkflowFile(storage, deploy, environments, cwd, cliSource, config);
30
+
31
+ console.log(chalk.green('✓ Regenerated GitHub Actions workflows:'));
32
+ console.log(` • .github/workflows/${DEPLOY_WORKFLOW_FILENAME}`);
33
+ console.log(` • .github/workflows/${ROLLBACK_WORKFLOW_FILENAME}`);
34
+ console.log('');
35
+ console.log(
36
+ chalk.gray(
37
+ 'Commit and push these files, then use Actions → DeployHub Rollback (workflow_dispatch) to roll back.'
38
+ )
39
+ );
40
+ });
41
+ }
42
+
43
+ export default { registerSyncWorkflowsCommand };
@@ -47,6 +47,7 @@ const EnvironmentSchema = z.object({
47
47
  const ConfigSchema = z.object({
48
48
  project: z.string(),
49
49
  version: z.string().optional(),
50
+ buildId: z.string().optional(),
50
51
  projectType: z.enum(['frontend', 'backend', 'both']).default('frontend'),
51
52
  framework: z.string().optional(),
52
53
  language: z.string().optional(),
@@ -6,6 +6,7 @@ import { deployToAll } from '../deployment/index.js';
6
6
  import { sendNotifications } from '../notifications/index.js';
7
7
  import axios from 'axios';
8
8
  import { getProjectVersion } from '../utils/version.js';
9
+ import { resolveBuildId } from '../utils/build-id.js';
9
10
  import { ensureDeployScaffold } from '../utils/scaffold.js';
10
11
 
11
12
  /**
@@ -41,8 +42,10 @@ export function buildPipelineStages(config, cwd, state) {
41
42
  ctx.config.port = detected.port;
42
43
  }
43
44
  }
44
- // Resolve version before docker so pipeline build and deploy share the same tag
45
+ // Semver label (package.json) + unique buildId (shared with image tag when DOCKER_IMAGE_TAG unset)
45
46
  ctx.config.version = await getProjectVersion(ctx.cwd);
47
+ const { buildId } = resolveBuildId({ semver: ctx.config.version });
48
+ ctx.config.buildId = buildId;
46
49
  const scaffold = await ensureDeployScaffold(
47
50
  ctx.cwd,
48
51
  ctx.config,
@@ -123,6 +126,10 @@ export function buildPipelineStages(config, cwd, state) {
123
126
  if (!ctx.config.version) {
124
127
  ctx.config.version = await getProjectVersion(ctx.cwd);
125
128
  }
129
+ if (!ctx.config.buildId) {
130
+ const { buildId } = resolveBuildId({ semver: ctx.config.version });
131
+ ctx.config.buildId = buildId;
132
+ }
126
133
  const result = await createArtifact(
127
134
  ctx.config,
128
135
  /** @type {string[]} */ (ctx.state.deployedTargets || []),
@@ -71,13 +71,14 @@ export async function deployToAll(config, artifactDir, envNames) {
71
71
  * @param {import('../core/config.js').DeployHubConfig} config
72
72
  * @param {string} artifactDir
73
73
  * @param {string[]} [envNames]
74
+ * @param {{ buildId?: string, semver?: string, remoteKey?: string }} [meta]
74
75
  */
75
- export async function rollbackAll(config, artifactDir, envNames) {
76
+ export async function rollbackAll(config, artifactDir, envNames, meta) {
76
77
  const targets = envNames || config.deploy || [];
77
78
  for (const envName of targets) {
78
79
  const envConfig = config.environments[envName];
79
80
  const provider = getDeploymentProvider(envConfig.type, config, envName);
80
- await provider.rollback(artifactDir);
81
+ await provider.rollback(artifactDir, meta);
81
82
  }
82
83
  }
83
84
 
@@ -21,7 +21,8 @@ export function createAzureVmProvider(config, envName, env = process.env) {
21
21
 
22
22
  if (!subscriptionId || !resourceGroup || !vmName) {
23
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.'
24
+ 'Could not resolve host via Azure VM lookup, and no SSH_HOST was set ' +
25
+ 'provide SSH_HOST (VM public IP/DNS) or set AZURE_SUBSCRIPTION_ID, AZURE_RESOURCE_GROUP, and AZURE_VM_NAME for auto lookup.'
25
26
  );
26
27
  }
27
28
 
@@ -55,34 +56,54 @@ export function createAzureVmProvider(config, envName, env = process.env) {
55
56
  } catch (err) {
56
57
  const msg = err instanceof Error ? err.message : String(err);
57
58
  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
+ `Could not resolve host via Azure VM lookup (${vmName}): ${msg}. ` +
60
+ 'Set SSH_HOST to the VM public IP/DNS, or run az login and verify resource group/VM name.'
59
61
  );
60
62
  }
61
63
  }
62
64
 
63
- const sshProvider = createSshProvider(config, envName, env);
64
-
65
- async function connect() {
65
+ /**
66
+ * Resolve host (skipping cloud lookup when SSH_HOST/environment.host is set),
67
+ * then create an SSH provider that closes over the resolved host.
68
+ */
69
+ async function getSshProvider() {
66
70
  const host = await resolveHost();
71
+ if (!host) {
72
+ throw new Error(
73
+ 'Could not resolve host via Azure VM lookup, and no SSH_HOST was set — provide one or the other.'
74
+ );
75
+ }
67
76
  const environment = config.environments[envName];
68
- if (environment && !environment.host) {
77
+ if (environment) {
69
78
  environment.host = host;
70
79
  }
71
- if (!env.SSH_HOST) {
72
- env.SSH_HOST = host;
73
- }
74
- return sshProvider.connect();
80
+ return createSshProvider(config, envName, { ...env, SSH_HOST: host });
75
81
  }
76
82
 
77
83
  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();
84
+ async connect() {
85
+ const ssh = await getSshProvider();
86
+ return ssh.connect();
87
+ },
88
+ async deploy(artifactDir, options) {
89
+ const ssh = await getSshProvider();
90
+ return ssh.deploy(artifactDir, options);
91
+ },
92
+ async rollback(artifactDir, meta) {
93
+ const ssh = await getSshProvider();
94
+ return ssh.rollback(artifactDir, meta);
95
+ },
96
+ async healthCheck() {
97
+ const ssh = await getSshProvider();
98
+ return ssh.healthCheck();
99
+ },
100
+ async testConnection() {
101
+ const ssh = await getSshProvider();
102
+ return ssh.testConnection();
103
+ },
104
+ async runRemoteCheck(command) {
105
+ const ssh = await getSshProvider();
106
+ return ssh.runRemoteCheck(command);
86
107
  },
87
108
  };
88
109
  }