@akash-chowdhury-24/deployhub 2.0.13 → 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.
@@ -20,6 +20,7 @@ import {
20
20
  parseArtifactHistory,
21
21
  prependHistoryEntry,
22
22
  } from '../utils/artifact-history.js';
23
+ import { summarizeStorageError } from './storage-errors.js';
23
24
 
24
25
  /** @type {Record<string, (env?: Record<string, string>) => ReturnType<typeof createAwsProvider>>} */
25
26
  const PROVIDER_FACTORIES = {
@@ -59,28 +60,51 @@ function ensureBuildIdentity(config) {
59
60
 
60
61
  /**
61
62
  * Read history.json from the first provider that has it.
63
+ * Missing keys across all providers → { entries: [], source: null }.
64
+ * Auth / network / permission failures → thrown with a concise actionable message
65
+ * (not silently treated as "no history").
66
+ *
62
67
  * @param {string[]} providers
63
68
  * @param {string} project
64
- * @returns {Promise<import('../utils/artifact-history.js').ArtifactHistoryEntry[]>}
69
+ * @returns {Promise<{
70
+ * entries: import('../utils/artifact-history.js').ArtifactHistoryEntry[],
71
+ * source: string|null,
72
+ * }>}
65
73
  */
66
74
  export async function loadArtifactHistory(providers, project) {
75
+ if (!providers || providers.length === 0) {
76
+ return { entries: [], source: null };
77
+ }
78
+
67
79
  const key = historyRemoteKey(project);
68
80
  const tmp = path.join(os.tmpdir(), `deployhub-history-${Date.now()}.json`);
81
+
69
82
  try {
70
83
  for (const name of providers) {
71
- const provider = getStorageProvider(name);
72
- const exists = await provider.verify(key);
73
- if (!exists) continue;
74
- await provider.download(key, tmp);
75
- const raw = await fs.readFile(tmp, 'utf8');
76
- return parseArtifactHistory(raw);
84
+ try {
85
+ const provider = getStorageProvider(name);
86
+ const exists = await provider.verify(key);
87
+ if (!exists) continue;
88
+
89
+ await provider.download(key, tmp);
90
+ const raw = await fs.readFile(tmp, 'utf8');
91
+ return {
92
+ entries: parseArtifactHistory(raw),
93
+ source: name,
94
+ };
95
+ } catch (err) {
96
+ const reason = summarizeStorageError(err);
97
+ throw new Error(
98
+ `Could not check remote history via ${name}: ${reason} — ` +
99
+ 'verify your storage credentials and configuration are correct.'
100
+ );
101
+ }
77
102
  }
78
- } catch {
79
- return [];
80
103
  } finally {
81
104
  await fs.remove(tmp).catch(() => {});
82
105
  }
83
- return [];
106
+
107
+ return { entries: [], source: null };
84
108
  }
85
109
 
86
110
  /**
@@ -2,6 +2,7 @@ import { S3Client, HeadBucketCommand, DeleteObjectCommand, GetObjectCommand } fr
2
2
  import { Upload } from '@aws-sdk/lib-storage';
3
3
  import fs from 'fs-extra';
4
4
  import path from 'path';
5
+ import { isNotFoundStorageError } from '../storage-errors.js';
5
6
 
6
7
  /**
7
8
  * @param {Record<string, string>} env
@@ -65,6 +66,8 @@ export function createAwsProvider(env = process.env) {
65
66
 
66
67
  /**
67
68
  * @param {string} remoteKey
69
+ * @returns {Promise<boolean>} true if object exists; false if missing.
70
+ * Auth / network / permission errors are rethrown (not treated as missing).
68
71
  */
69
72
  async function verify(remoteKey) {
70
73
  try {
@@ -72,8 +75,9 @@ export function createAwsProvider(env = process.env) {
72
75
  new GetObjectCommand({ Bucket: bucket, Key: remoteKey })
73
76
  );
74
77
  return true;
75
- } catch {
76
- return false;
78
+ } catch (err) {
79
+ if (isNotFoundStorageError(err)) return false;
80
+ throw err;
77
81
  }
78
82
  }
79
83
 
@@ -1,6 +1,7 @@
1
1
  import { Dropbox } from 'dropbox';
2
2
  import fs from 'fs-extra';
3
3
  import path from 'path';
4
+ import { isNotFoundStorageError } from '../storage-errors.js';
4
5
 
5
6
  export function createDropboxProvider(env = process.env) {
6
7
  const token = env.DROPBOX_ACCESS_TOKEN;
@@ -24,13 +25,18 @@ export function createDropboxProvider(env = process.env) {
24
25
  await fs.writeFile(localPath, fileBlob);
25
26
  }
26
27
 
28
+ /**
29
+ * @param {string} remoteKey
30
+ * @returns {Promise<boolean>}
31
+ */
27
32
  async function verify(remoteKey) {
28
33
  const key = remoteKey.startsWith('/') ? remoteKey : `/${remoteKey}`;
29
34
  try {
30
35
  await dbx.filesGetMetadata({ path: key });
31
36
  return true;
32
- } catch {
33
- return false;
37
+ } catch (err) {
38
+ if (isNotFoundStorageError(err)) return false;
39
+ throw err;
34
40
  }
35
41
  }
36
42
 
@@ -1,6 +1,7 @@
1
1
  import { Client } from 'basic-ftp';
2
2
  import fs from 'fs-extra';
3
3
  import path from 'path';
4
+ import { isNotFoundStorageError } from '../storage-errors.js';
4
5
 
5
6
  export function createFtpProvider(env = process.env) {
6
7
  const host = env.FTP_HOST;
@@ -38,6 +39,10 @@ export function createFtpProvider(env = process.env) {
38
39
  });
39
40
  }
40
41
 
42
+ /**
43
+ * @param {string} remoteKey
44
+ * @returns {Promise<boolean>}
45
+ */
41
46
  async function verify(remoteKey) {
42
47
  const remotePath = `${basePath}/${remoteKey}`;
43
48
  try {
@@ -45,8 +50,9 @@ export function createFtpProvider(env = process.env) {
45
50
  await client.size(remotePath);
46
51
  });
47
52
  return true;
48
- } catch {
49
- return false;
53
+ } catch (err) {
54
+ if (isNotFoundStorageError(err)) return false;
55
+ throw err;
50
56
  }
51
57
  }
52
58
 
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Shared helpers for storage provider errors (history load, verify, etc.).
3
+ */
4
+
5
+ /**
6
+ * Concise, actionable one-line reason — no SDK stack dumps.
7
+ * @param {unknown} err
8
+ * @returns {string}
9
+ */
10
+ export function summarizeStorageError(err) {
11
+ if (!err) return 'unknown error';
12
+
13
+ if (typeof err === 'string') {
14
+ return truncateReason(err);
15
+ }
16
+
17
+ const e = /** @type {Record<string, unknown>} */ (err);
18
+ const name = typeof e.name === 'string' ? e.name : '';
19
+ const code =
20
+ typeof e.Code === 'string'
21
+ ? e.Code
22
+ : typeof e.code === 'string'
23
+ ? e.code
24
+ : '';
25
+ const message =
26
+ err instanceof Error
27
+ ? err.message
28
+ : typeof e.message === 'string'
29
+ ? e.message
30
+ : String(err);
31
+
32
+ const firstLine = message.split(/\r?\n/)[0].trim();
33
+
34
+ // Prefer a short "AccessDenied: ..." style when the SDK exposes a name/code.
35
+ const label = [name, code].find(
36
+ (v) => v && v !== 'Error' && !firstLine.includes(v)
37
+ );
38
+ const combined = label ? `${label}: ${firstLine}` : firstLine;
39
+ return truncateReason(combined || 'unknown error');
40
+ }
41
+
42
+ /**
43
+ * @param {string} text
44
+ */
45
+ function truncateReason(text) {
46
+ const cleaned = text.replace(/\s+/g, ' ').trim();
47
+ if (cleaned.length <= 200) return cleaned;
48
+ return `${cleaned.slice(0, 197)}...`;
49
+ }
50
+
51
+ /**
52
+ * True when an error almost certainly means the object/key is missing
53
+ * (as opposed to auth, network, or permission failures).
54
+ * @param {unknown} err
55
+ * @returns {boolean}
56
+ */
57
+ export function isNotFoundStorageError(err) {
58
+ if (!err || typeof err !== 'object') return false;
59
+ const e = /** @type {Record<string, unknown>} */ (err);
60
+ const status =
61
+ (e.$metadata &&
62
+ typeof e.$metadata === 'object' &&
63
+ /** @type {{ httpStatusCode?: number }} */ (e.$metadata).httpStatusCode) ||
64
+ (typeof e.statusCode === 'number' ? e.statusCode : undefined) ||
65
+ (typeof e.status === 'number' ? e.status : undefined);
66
+
67
+ const name = String(e.name || e.Code || e.code || '');
68
+ const msg = String(e.message || '').toLowerCase();
69
+ const blob = `${name} ${msg}`;
70
+
71
+ // Auth / permission / credential failures are never "not found"
72
+ if (
73
+ /\b(accessdenied|access denied|invalidaccesskey|forbidden|unauthorized|credentials|signaturedoesnotmatch|expiredtoken)\b/i.test(
74
+ blob
75
+ )
76
+ ) {
77
+ return false;
78
+ }
79
+
80
+ if (status === 403 || status === 401) return false;
81
+
82
+ if (status === 404) return true;
83
+
84
+ if (/^(NoSuchKey|NotFound|NotFoundError|ENOENT)$/i.test(name)) return true;
85
+
86
+ if (
87
+ /\b(nosuchkey|not\s*found|no such file|path\/not_found)\b/i.test(msg) ||
88
+ /\bthe specified key does not exist\b/i.test(msg)
89
+ ) {
90
+ return true;
91
+ }
92
+
93
+ // Dropbox often uses 409 with path/not_found
94
+ if (status === 409 && /not_found/i.test(msg + name + JSON.stringify(e.error || ''))) {
95
+ return true;
96
+ }
97
+
98
+ // FTP / classic responses
99
+ if (/\b550\b/.test(msg) && /not found|no such|failed to open/i.test(msg)) {
100
+ return true;
101
+ }
102
+
103
+ return false;
104
+ }
105
+
106
+ export default { summarizeStorageError, isNotFoundStorageError };
@@ -10,6 +10,7 @@ import {
10
10
  generateSpringRuntimeDockerfile,
11
11
  isFrontendStaticFramework,
12
12
  isInterpretedBackendFramework,
13
+ replaceDockerImageTag,
13
14
  resolveDockerImageRef,
14
15
  EXPLICIT_IMAGE_TAG_WARNING,
15
16
  } from './docker-image.js';
@@ -67,8 +68,9 @@ export function createDockerImageDeployContext(config, env = process.env, log) {
67
68
  /**
68
69
  * Push only when registry credentials are configured. Avoids a noisy failed
69
70
  * push to Docker Hub for local-only image names.
71
+ * @param {string} [imageRef]
70
72
  */
71
- async function maybePushImage() {
73
+ async function maybePushImage(imageRef = fullImage) {
72
74
  if (!hasRegistryCredentials()) {
73
75
  log.info(
74
76
  'docker push skipped (DOCKER_REGISTRY_USERNAME/TOKEN not set — local image only)'
@@ -76,12 +78,12 @@ export function createDockerImageDeployContext(config, env = process.env, log) {
76
78
  return;
77
79
  }
78
80
 
79
- log.info(`Pushing ${fullImage} to registry...`);
80
- await execa('docker', ['push', fullImage], {
81
+ log.info(`Pushing ${imageRef} to registry...`);
82
+ await execa('docker', ['push', imageRef], {
81
83
  stdio: 'inherit',
82
84
  env: getDockerEnv(),
83
85
  });
84
- log.success(`Pushed ${fullImage}`);
86
+ log.success(`Pushed ${imageRef}`);
85
87
  }
86
88
 
87
89
  /**
@@ -102,21 +104,22 @@ export function createDockerImageDeployContext(config, env = process.env, log) {
102
104
  /**
103
105
  * Prefer the image already built during the pipeline `docker` stage.
104
106
  * Retag when the pipeline used `:latest` and deploy needs a version tag.
107
+ * @param {string} [imageRef]
105
108
  */
106
- async function ensureImageFromPipeline() {
107
- if (await imageExistsLocally(fullImage)) {
108
- log.info(`Reusing existing image ${fullImage}`);
109
+ async function ensureImageFromPipeline(imageRef = fullImage) {
110
+ if (await imageExistsLocally(imageRef)) {
111
+ log.info(`Reusing existing image ${imageRef}`);
109
112
  return true;
110
113
  }
111
114
 
112
115
  const candidates = [...new Set([latestImage, legacyLatestImage])].filter(
113
- (ref) => ref !== fullImage
116
+ (ref) => ref !== imageRef
114
117
  );
115
118
 
116
119
  for (const candidate of candidates) {
117
120
  if (!(await imageExistsLocally(candidate))) continue;
118
- log.info(`Re-tagging pipeline image ${candidate} → ${fullImage}`);
119
- await execa('docker', ['tag', candidate, fullImage], {
121
+ log.info(`Re-tagging pipeline image ${candidate} → ${imageRef}`);
122
+ await execa('docker', ['tag', candidate, imageRef], {
120
123
  stdio: 'inherit',
121
124
  env: getDockerEnv(),
122
125
  });
@@ -211,12 +214,13 @@ export function createDockerImageDeployContext(config, env = process.env, log) {
211
214
 
212
215
  /**
213
216
  * @param {string} artifactDir
217
+ * @param {string} [imageRef]
214
218
  */
215
- async function buildFromArtifactContents(artifactDir) {
219
+ async function buildFromArtifactContents(artifactDir, imageRef = fullImage) {
216
220
  const zipPath = path.join(artifactDir, 'artifact.zip');
217
221
  if (!(await fs.pathExists(zipPath))) {
218
222
  throw new Error(
219
- `No local image found for ${fullImage} and no artifact.zip to build from. ` +
223
+ `No local image found for ${imageRef} and no artifact.zip to build from. ` +
220
224
  'Enable pipeline.docker so the image is built from project source, or run deployhub build first.'
221
225
  );
222
226
  }
@@ -281,7 +285,7 @@ export function createDockerImageDeployContext(config, env = process.env, log) {
281
285
  await prepareBackendBuildContext(buildContext, metadata, framework, port);
282
286
  }
283
287
 
284
- await execa('docker', ['build', '-t', fullImage, '.'], {
288
+ await execa('docker', ['build', '-t', imageRef, '.'], {
285
289
  cwd: buildContext,
286
290
  stdio: 'inherit',
287
291
  env: getDockerEnv(),
@@ -295,36 +299,55 @@ export function createDockerImageDeployContext(config, env = process.env, log) {
295
299
  /**
296
300
  * Ensure a deployable image exists locally, then push when credentials are set.
297
301
  * @param {string} artifactDir
298
- * @param {{ skipPush?: boolean }} [options]
302
+ * @param {{
303
+ * skipPush?: boolean,
304
+ * fullImage?: string,
305
+ * skipImageReuse?: boolean,
306
+ * }} [options]
299
307
  */
300
308
  async function ensureImageReadyForDeploy(artifactDir, options = {}) {
309
+ const imageRef = options.fullImage || fullImage;
310
+ const latestRef = options.fullImage
311
+ ? replaceDockerImageTag(options.fullImage, 'latest')
312
+ : latestImage;
313
+ const lastSlash = imageRef.lastIndexOf('/');
314
+ const lastColon = imageRef.lastIndexOf(':');
315
+ const effectiveTag =
316
+ lastColon > lastSlash ? imageRef.slice(lastColon + 1) : 'latest';
317
+
301
318
  await dockerLogin();
302
319
 
303
- const reused = await ensureImageFromPipeline();
320
+ let reused = false;
321
+ if (!options.skipImageReuse) {
322
+ reused = await ensureImageFromPipeline(imageRef);
323
+ } else {
324
+ log.info(`Skipping local image reuse — rebuilding ${imageRef} from artifact`);
325
+ }
326
+
304
327
  let ranCompose = false;
305
328
 
306
329
  if (!reused) {
307
- const result = await buildFromArtifactContents(artifactDir);
330
+ const result = await buildFromArtifactContents(artifactDir, imageRef);
308
331
  ranCompose = Boolean(result?.ranCompose);
309
332
  }
310
333
 
311
334
  if (ranCompose) {
312
- return { ranCompose: true };
335
+ return { ranCompose: true, fullImage: imageRef };
313
336
  }
314
337
 
315
338
  if (!options.skipPush) {
316
- await maybePushImage();
339
+ await maybePushImage(imageRef);
317
340
  }
318
341
 
319
342
  const dockerEnv = getDockerEnv();
320
- if (imageTag !== 'latest' && fullImage !== latestImage) {
321
- await execa('docker', ['tag', fullImage, latestImage], {
343
+ if (effectiveTag !== 'latest' && imageRef !== latestRef) {
344
+ await execa('docker', ['tag', imageRef, latestRef], {
322
345
  stdio: 'pipe',
323
346
  env: dockerEnv,
324
347
  }).catch(() => {});
325
348
  }
326
349
 
327
- return { ranCompose: false };
350
+ return { ranCompose: false, fullImage: imageRef };
328
351
  }
329
352
 
330
353
  return {
@@ -41,12 +41,12 @@ export function resolveImageTag(env = process.env, options = {}) {
41
41
  }
42
42
 
43
43
  /**
44
+ * Same repository naming as resolveDockerImageRef, with an explicit tag.
45
+ * Ignores DOCKER_IMAGE_TAG / git / CI — used when restoring a known buildId.
46
+ *
44
47
  * @param {import('../core/config.js').DeployHubConfig} config
45
48
  * @param {Record<string, string|undefined>} [env]
46
- * @param {{
47
- * getGitShortSha?: () => string|null,
48
- * now?: () => Date,
49
- * }} [options]
49
+ * @param {string} imageTag
50
50
  * @returns {{
51
51
  * imageName: string,
52
52
  * imageTag: string,
@@ -56,12 +56,8 @@ export function resolveImageTag(env = process.env, options = {}) {
56
56
  * tagSource: ImageTagSource,
57
57
  * }}
58
58
  */
59
- export function resolveDockerImageRef(config, env = process.env, options = {}) {
59
+ export function resolveDockerImageRefForTag(config, env = process.env, imageTag) {
60
60
  const imageName = env.DOCKER_IMAGE_NAME || config.project;
61
- const { imageTag, tagSource } = resolveImageTag(env, {
62
- ...options,
63
- buildId: /** @type {{ buildId?: string }} */ (config).buildId,
64
- });
65
61
  const registryUrl = env.DOCKER_REGISTRY_URL || '';
66
62
 
67
63
  const repository =
@@ -75,6 +71,47 @@ export function resolveDockerImageRef(config, env = process.env, options = {}) {
75
71
  fullImage: `${repository}:${imageTag}`,
76
72
  latestImage: `${repository}:latest`,
77
73
  legacyLatestImage: `${config.project}:latest`,
74
+ tagSource: 'buildId',
75
+ };
76
+ }
77
+
78
+ /**
79
+ * Replace the tag portion of a docker image ref (handles registry:port/name:tag).
80
+ * @param {string} imageRef
81
+ * @param {string} newTag
82
+ */
83
+ export function replaceDockerImageTag(imageRef, newTag) {
84
+ const lastSlash = imageRef.lastIndexOf('/');
85
+ const lastColon = imageRef.lastIndexOf(':');
86
+ if (lastColon > lastSlash) {
87
+ return `${imageRef.slice(0, lastColon)}:${newTag}`;
88
+ }
89
+ return `${imageRef}:${newTag}`;
90
+ }
91
+
92
+ /**
93
+ * @param {import('../core/config.js').DeployHubConfig} config
94
+ * @param {Record<string, string|undefined>} [env]
95
+ * @param {{
96
+ * getGitShortSha?: () => string|null,
97
+ * now?: () => Date,
98
+ * }} [options]
99
+ * @returns {{
100
+ * imageName: string,
101
+ * imageTag: string,
102
+ * fullImage: string,
103
+ * latestImage: string,
104
+ * legacyLatestImage: string,
105
+ * tagSource: ImageTagSource,
106
+ * }}
107
+ */
108
+ export function resolveDockerImageRef(config, env = process.env, options = {}) {
109
+ const { imageTag, tagSource } = resolveImageTag(env, {
110
+ ...options,
111
+ buildId: /** @type {{ buildId?: string }} */ (config).buildId,
112
+ });
113
+ return {
114
+ ...resolveDockerImageRefForTag(config, env, imageTag),
78
115
  tagSource,
79
116
  };
80
117
  }
@@ -217,6 +254,8 @@ export function describeInterpretedBackendGap(framework) {
217
254
 
218
255
  export default {
219
256
  resolveDockerImageRef,
257
+ resolveDockerImageRefForTag,
258
+ replaceDockerImageTag,
220
259
  resolveImageTag,
221
260
  highResImageTagFallback,
222
261
  EXPLICIT_IMAGE_TAG_WARNING,