@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.
@@ -1,6 +1,7 @@
1
1
  import { execa } from 'execa';
2
2
  import { createLogger } from '../../logger/index.js';
3
3
  import { createDockerImageDeployContext } from '../../utils/docker-image-deploy.js';
4
+ import { resolveDockerImageRefForTag } from '../../utils/docker-image.js';
4
5
 
5
6
  /**
6
7
  * @param {import('../../core/config.js').DeployHubConfig} config
@@ -14,12 +15,17 @@ export function createDockerProvider(config, envName, env = process.env) {
14
15
 
15
16
  /**
16
17
  * @param {string} artifactDir
18
+ * @param {{ fullImage?: string, skipImageReuse?: boolean }} [options]
17
19
  */
18
- async function deploy(artifactDir) {
19
- log.info(`Deploying via Docker (image: ${fullImage})...`);
20
+ async function deploy(artifactDir, options = {}) {
21
+ const imageRef = options.fullImage || fullImage;
22
+ log.info(`Deploying via Docker (image: ${imageRef})...`);
20
23
  const dockerEnv = getDockerEnv();
21
24
 
22
- const result = await ensureImageReadyForDeploy(artifactDir);
25
+ const result = await ensureImageReadyForDeploy(artifactDir, {
26
+ fullImage: options.fullImage,
27
+ skipImageReuse: options.skipImageReuse,
28
+ });
23
29
  if (result.ranCompose) {
24
30
  log.success('Docker deployment complete');
25
31
  return;
@@ -31,7 +37,7 @@ export function createDockerProvider(config, envName, env = process.env) {
31
37
  { stdio: 'pipe', env: dockerEnv }
32
38
  ).catch(() => {});
33
39
 
34
- await execa('docker', ['run', '-d', '--rm', '--name', config.project, fullImage], {
40
+ await execa('docker', ['run', '-d', '--rm', '--name', config.project, imageRef], {
35
41
  stdio: 'inherit',
36
42
  env: dockerEnv,
37
43
  });
@@ -39,9 +45,25 @@ export function createDockerProvider(config, envName, env = process.env) {
39
45
  log.success('Docker deployment complete');
40
46
  }
41
47
 
42
- async function rollback(artifactDir) {
43
- log.info('Rolling back Docker deployment (redeploy previous artifact)...');
44
- await deploy(artifactDir);
48
+ /**
49
+ * @param {string} artifactDir
50
+ * @param {{ buildId?: string, semver?: string, remoteKey?: string }} [meta]
51
+ */
52
+ async function rollback(artifactDir, meta = {}) {
53
+ if (!meta.buildId) {
54
+ throw new Error(
55
+ 'Docker rollback requires buildId from the restored artifact history entry'
56
+ );
57
+ }
58
+
59
+ const rollbackImage = resolveDockerImageRefForTag(config, env, meta.buildId).fullImage;
60
+ log.info(
61
+ `Rolling back Docker to buildId=${meta.buildId} (image: ${rollbackImage})...`
62
+ );
63
+ await deploy(artifactDir, {
64
+ fullImage: rollbackImage,
65
+ skipImageReuse: true,
66
+ });
45
67
  }
46
68
 
47
69
  async function healthCheck() {
@@ -20,7 +20,8 @@ export function createEc2Provider(config, envName, env = process.env) {
20
20
 
21
21
  if (!instanceId) {
22
22
  throw new Error(
23
- 'EC2 host unknown. Set SSH_HOST to your instance public IP, or set EC2_INSTANCE_ID with AWS credentials for auto lookup.'
23
+ 'Could not resolve host via EC2 instance lookup, and no SSH_HOST was set — ' +
24
+ 'provide SSH_HOST (instance public IP/DNS) or set EC2_INSTANCE_ID with AWS credentials for auto lookup.'
24
25
  );
25
26
  }
26
27
 
@@ -58,34 +59,54 @@ export function createEc2Provider(config, envName, env = process.env) {
58
59
  } catch (err) {
59
60
  const msg = err instanceof Error ? err.message : String(err);
60
61
  throw new Error(
61
- `Could not resolve public IP for ${instanceId} — ${msg}. Set SSH_HOST manually or install/configure AWS CLI with ec2:DescribeInstances access.`
62
+ `Could not resolve host via EC2 instance lookup (${instanceId}): ${msg}. ` +
63
+ 'Set SSH_HOST to the instance public IP/DNS, or fix AWS CLI credentials / ec2:DescribeInstances access.'
62
64
  );
63
65
  }
64
66
  }
65
67
 
66
- const sshProvider = createSshProvider(config, envName, env);
67
-
68
- async function connect() {
68
+ /**
69
+ * Resolve host (skipping cloud lookup when SSH_HOST/environment.host is set),
70
+ * then create an SSH provider that closes over the resolved host.
71
+ */
72
+ async function getSshProvider() {
69
73
  const host = await resolveHost();
74
+ if (!host) {
75
+ throw new Error(
76
+ 'Could not resolve host via EC2 instance lookup, and no SSH_HOST was set — provide one or the other.'
77
+ );
78
+ }
70
79
  const environment = config.environments[envName];
71
- if (environment && !environment.host) {
80
+ if (environment) {
72
81
  environment.host = host;
73
82
  }
74
- if (!env.SSH_HOST) {
75
- env.SSH_HOST = host;
76
- }
77
- return sshProvider.connect();
83
+ return createSshProvider(config, envName, { ...env, SSH_HOST: host });
78
84
  }
79
85
 
80
86
  return {
81
- ...sshProvider,
82
- connect,
83
- deploy: sshProvider.deploy.bind(sshProvider),
84
- rollback: sshProvider.rollback.bind(sshProvider),
85
- healthCheck: sshProvider.healthCheck.bind(sshProvider),
86
- testConnection: async () => {
87
- const ssh = await connect();
88
- ssh.dispose();
87
+ async connect() {
88
+ const ssh = await getSshProvider();
89
+ return ssh.connect();
90
+ },
91
+ async deploy(artifactDir, options) {
92
+ const ssh = await getSshProvider();
93
+ return ssh.deploy(artifactDir, options);
94
+ },
95
+ async rollback(artifactDir, meta) {
96
+ const ssh = await getSshProvider();
97
+ return ssh.rollback(artifactDir, meta);
98
+ },
99
+ async healthCheck() {
100
+ const ssh = await getSshProvider();
101
+ return ssh.healthCheck();
102
+ },
103
+ async testConnection() {
104
+ const ssh = await getSshProvider();
105
+ return ssh.testConnection();
106
+ },
107
+ async runRemoteCheck(command) {
108
+ const ssh = await getSshProvider();
109
+ return ssh.runRemoteCheck(command);
89
110
  },
90
111
  };
91
112
  }
@@ -21,7 +21,8 @@ export function createGcpVmProvider(config, envName, env = process.env) {
21
21
 
22
22
  if (!projectId || !zone || !instanceName) {
23
23
  throw new Error(
24
- 'GCP VM host unknown. Set SSH_HOST to your instance external IP, or set GCP_PROJECT_ID, GCP_ZONE, and GCP_INSTANCE_NAME for auto lookup.'
24
+ 'Could not resolve host via GCP instance lookup, and no SSH_HOST was set — ' +
25
+ 'provide SSH_HOST (instance external IP/DNS) or set GCP_PROJECT_ID, GCP_ZONE, and GCP_INSTANCE_NAME for auto lookup.'
25
26
  );
26
27
  }
27
28
 
@@ -59,34 +60,54 @@ export function createGcpVmProvider(config, envName, env = process.env) {
59
60
  } catch (err) {
60
61
  const msg = err instanceof Error ? err.message : String(err);
61
62
  throw new Error(
62
- `Could not resolve external IP for ${instanceName} — ${msg}. Set SSH_HOST manually or run gcloud auth login and verify project/zone/instance name.`
63
+ `Could not resolve host via GCP instance lookup (${instanceName}): ${msg}. ` +
64
+ 'Set SSH_HOST to the instance external IP/DNS, or run gcloud auth login and verify project/zone/instance name.'
63
65
  );
64
66
  }
65
67
  }
66
68
 
67
- const sshProvider = createSshProvider(config, envName, env);
68
-
69
- async function connect() {
69
+ /**
70
+ * Resolve host (skipping cloud lookup when SSH_HOST/environment.host is set),
71
+ * then create an SSH provider that closes over the resolved host.
72
+ */
73
+ async function getSshProvider() {
70
74
  const host = await resolveHost();
75
+ if (!host) {
76
+ throw new Error(
77
+ 'Could not resolve host via GCP instance lookup, and no SSH_HOST was set — provide one or the other.'
78
+ );
79
+ }
71
80
  const environment = config.environments[envName];
72
- if (environment && !environment.host) {
81
+ if (environment) {
73
82
  environment.host = host;
74
83
  }
75
- if (!env.SSH_HOST) {
76
- env.SSH_HOST = host;
77
- }
78
- return sshProvider.connect();
84
+ return createSshProvider(config, envName, { ...env, SSH_HOST: host });
79
85
  }
80
86
 
81
87
  return {
82
- ...sshProvider,
83
- connect,
84
- deploy: sshProvider.deploy.bind(sshProvider),
85
- rollback: sshProvider.rollback.bind(sshProvider),
86
- healthCheck: sshProvider.healthCheck.bind(sshProvider),
87
- testConnection: async () => {
88
- const ssh = await connect();
89
- ssh.dispose();
88
+ async connect() {
89
+ const ssh = await getSshProvider();
90
+ return ssh.connect();
91
+ },
92
+ async deploy(artifactDir, options) {
93
+ const ssh = await getSshProvider();
94
+ return ssh.deploy(artifactDir, options);
95
+ },
96
+ async rollback(artifactDir, meta) {
97
+ const ssh = await getSshProvider();
98
+ return ssh.rollback(artifactDir, meta);
99
+ },
100
+ async healthCheck() {
101
+ const ssh = await getSshProvider();
102
+ return ssh.healthCheck();
103
+ },
104
+ async testConnection() {
105
+ const ssh = await getSshProvider();
106
+ return ssh.testConnection();
107
+ },
108
+ async runRemoteCheck(command) {
109
+ const ssh = await getSshProvider();
110
+ return ssh.runRemoteCheck(command);
90
111
  },
91
112
  };
92
113
  }
@@ -5,6 +5,7 @@ import os from 'os';
5
5
  import { createLogger } from '../../logger/index.js';
6
6
  import { sanitizeK8sName } from '../../utils/kubernetes-manifests.js';
7
7
  import { createDockerImageDeployContext } from '../../utils/docker-image-deploy.js';
8
+ import { resolveDockerImageRefForTag } from '../../utils/docker-image.js';
8
9
  import { ensureKubernetesNamespace } from '../../utils/kubernetes-namespace.js';
9
10
  import { syncKubernetesDeploymentImage } from '../../utils/kubernetes-deploy-image.js';
10
11
 
@@ -50,10 +51,24 @@ export function createKubernetesProvider(config, envName, env = process.env) {
50
51
 
51
52
  /**
52
53
  * @param {string} artifactDir
54
+ * @param {{ fullImage?: string, skipImageReuse?: boolean }} [options]
53
55
  */
54
- async function deploy(artifactDir) {
56
+ async function deploy(artifactDir, options = {}) {
57
+ const imageRef = options.fullImage || imageOps.fullImage;
58
+ const isRollbackRedeploy = Boolean(options.skipImageReuse);
59
+
55
60
  log.info(`Deploying to Kubernetes (namespace: ${namespace}${context ? `, context: ${context}` : ''})...`);
56
61
 
62
+ // Rollback always rebuilds and must push — without registry creds the cluster
63
+ // cannot pull the new tag and would sit in ImagePullBackOff after a false success.
64
+ if (isRollbackRedeploy && !imageOps.hasRegistryCredentials()) {
65
+ throw new Error(
66
+ 'Kubernetes rollback requires DOCKER_REGISTRY_USERNAME and DOCKER_REGISTRY_TOKEN ' +
67
+ `so the rebuilt image (${imageRef}) can be pushed for the cluster to pull. ` +
68
+ 'Set those credentials and retry.'
69
+ );
70
+ }
71
+
57
72
  const manifestDir = artifactDir;
58
73
  const hasManifests =
59
74
  (await fs.pathExists(path.join(manifestDir, 'k8s'))) ||
@@ -65,8 +80,11 @@ export function createKubernetesProvider(config, envName, env = process.env) {
65
80
  );
66
81
  }
67
82
 
68
- log.info(`Ensuring container image ${imageOps.fullImage} is built and pushed before apply...`);
69
- const imageResult = await imageOps.ensureImageReadyForDeploy(artifactDir);
83
+ log.info(`Ensuring container image ${imageRef} is built and pushed before apply...`);
84
+ const imageResult = await imageOps.ensureImageReadyForDeploy(artifactDir, {
85
+ fullImage: options.fullImage,
86
+ skipImageReuse: options.skipImageReuse,
87
+ });
70
88
  if (imageResult.ranCompose) {
71
89
  log.warn(
72
90
  'docker compose was used — ensure the cluster can pull the resulting image from your registry.'
@@ -93,23 +111,60 @@ export function createKubernetesProvider(config, envName, env = process.env) {
93
111
  // If the live ref already equals fullImage, rollout restart so a new digest is pulled.
94
112
  await syncKubernetesDeploymentImage({
95
113
  deploymentName,
96
- fullImage: imageOps.fullImage,
114
+ fullImage: imageRef,
97
115
  kubectlArgs,
98
116
  getKubectlEnv,
99
117
  log,
100
118
  });
101
119
 
120
+ // Rollback-only safety net: wait until pods are actually healthy (catches ImagePullBackOff).
121
+ if (isRollbackRedeploy) {
122
+ const timeout = '120s';
123
+ log.info(
124
+ `Waiting for deployment/${deploymentName} rollout to complete (timeout ${timeout})...`
125
+ );
126
+ try {
127
+ await execa(
128
+ 'kubectl',
129
+ kubectlArgs([
130
+ 'rollout',
131
+ 'status',
132
+ `deployment/${deploymentName}`,
133
+ `--timeout=${timeout}`,
134
+ ]),
135
+ { stdio: 'inherit', env: getKubectlEnv() }
136
+ );
137
+ } catch (err) {
138
+ const detail = err instanceof Error ? err.message : String(err);
139
+ throw new Error(
140
+ `Kubernetes rollback failed: deployment/${deploymentName} did not become healthy within ${timeout}. ` +
141
+ `The cluster may be unable to pull ${imageRef} (ImagePullBackOff) or pods are failing. ${detail}`
142
+ );
143
+ }
144
+ }
145
+
102
146
  log.success('Kubernetes deployment complete');
103
147
  }
104
148
 
105
- async function rollback(artifactDir) {
106
- log.info('Rolling back Kubernetes deployment...');
107
- await execa('kubectl', kubectlArgs(['rollout', 'undo', `deployment/${deploymentName}`]), {
108
- stdio: 'inherit',
109
- env: getKubectlEnv(),
110
- }).catch(async () => {
111
- log.warn('kubectl rollout undo failed — redeploying previous artifact instead');
112
- await deploy(artifactDir);
149
+ /**
150
+ * Artifact-based rollback: restore buildId X's code and image (not cluster undo history).
151
+ * @param {string} artifactDir
152
+ * @param {{ buildId?: string, semver?: string, remoteKey?: string }} [meta]
153
+ */
154
+ async function rollback(artifactDir, meta = {}) {
155
+ if (!meta.buildId) {
156
+ throw new Error(
157
+ 'Kubernetes rollback requires buildId from the restored artifact history entry'
158
+ );
159
+ }
160
+
161
+ const rollbackImage = resolveDockerImageRefForTag(config, env, meta.buildId).fullImage;
162
+ log.info(
163
+ `Rolling back Kubernetes to buildId=${meta.buildId} (image: ${rollbackImage})...`
164
+ );
165
+ await deploy(artifactDir, {
166
+ fullImage: rollbackImage,
167
+ skipImageReuse: true,
113
168
  });
114
169
  }
115
170
 
@@ -405,7 +405,7 @@ export function createSshProvider(config, envName, env = process.env) {
405
405
  return result.code === 0 && result.stdout.trim() === 'yes';
406
406
  }
407
407
 
408
- async function rollback(artifactDir) {
408
+ async function rollback(artifactDir, _meta) {
409
409
  await deploy(artifactDir);
410
410
  }
411
411
 
@@ -1,4 +1,6 @@
1
1
  import path from 'path';
2
+ import fs from 'fs-extra';
3
+ import os from 'os';
2
4
  import { createLogger } from '../logger/index.js';
3
5
  import { createAwsProvider } from './providers/aws.js';
4
6
  import { createLocalProvider } from './providers/local.js';
@@ -7,8 +9,20 @@ import { createGcpProvider } from './providers/gcp.js';
7
9
  import { createGdriveProvider } from './providers/gdrive.js';
8
10
  import { createDropboxProvider } from './providers/dropbox.js';
9
11
  import { createFtpProvider } from './providers/ftp.js';
12
+ import {
13
+ buildArtifactRemoteKey,
14
+ historyRemoteKey,
15
+ latestArtifactRemoteKey,
16
+ legacyArtifactRemoteKey,
17
+ resolveBuildId,
18
+ } from '../utils/build-id.js';
19
+ import {
20
+ parseArtifactHistory,
21
+ prependHistoryEntry,
22
+ } from '../utils/artifact-history.js';
23
+ import { summarizeStorageError } from './storage-errors.js';
10
24
 
11
- /** @type {Record<string, (env?: Record<string, string>) => import('./providers/aws.js').default>} */
25
+ /** @type {Record<string, (env?: Record<string, string>) => ReturnType<typeof createAwsProvider>>} */
12
26
  const PROVIDER_FACTORIES = {
13
27
  aws: createAwsProvider,
14
28
  local: createLocalProvider,
@@ -32,21 +46,135 @@ export function getStorageProvider(name, env = process.env) {
32
46
  }
33
47
 
34
48
  /**
49
+ * @param {import('../core/config.js').DeployHubConfig} config
50
+ */
51
+ function ensureBuildIdentity(config) {
52
+ if (!config.version) {
53
+ config.version = '0.0.0';
54
+ }
55
+ if (!config.buildId) {
56
+ const { buildId } = resolveBuildId({ semver: config.version });
57
+ config.buildId = buildId;
58
+ }
59
+ }
60
+
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
+ *
67
+ * @param {string[]} providers
68
+ * @param {string} project
69
+ * @returns {Promise<{
70
+ * entries: import('../utils/artifact-history.js').ArtifactHistoryEntry[],
71
+ * source: string|null,
72
+ * }>}
73
+ */
74
+ export async function loadArtifactHistory(providers, project) {
75
+ if (!providers || providers.length === 0) {
76
+ return { entries: [], source: null };
77
+ }
78
+
79
+ const key = historyRemoteKey(project);
80
+ const tmp = path.join(os.tmpdir(), `deployhub-history-${Date.now()}.json`);
81
+
82
+ try {
83
+ for (const name of providers) {
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
+ }
102
+ }
103
+ } finally {
104
+ await fs.remove(tmp).catch(() => {});
105
+ }
106
+
107
+ return { entries: [], source: null };
108
+ }
109
+
110
+ /**
111
+ * @param {ReturnType<typeof getStorageProvider>} provider
112
+ * @param {string} project
113
+ * @param {import('../utils/artifact-history.js').ArtifactHistoryEntry[]} history
114
+ */
115
+ async function writeArtifactHistory(provider, project, history) {
116
+ const key = historyRemoteKey(project);
117
+ const tmp = path.join(os.tmpdir(), `deployhub-history-write-${Date.now()}.json`);
118
+ await fs.writeJson(tmp, history, { spaces: 2 });
119
+ try {
120
+ await provider.upload(tmp, key);
121
+ } finally {
122
+ await fs.remove(tmp).catch(() => {});
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Upload artifact zip under a unique build key, update latest/ pointer and history.json.
128
+ * Does NOT write legacy `{project}/v{semver}/artifact.zip` (retired for new uploads;
129
+ * legacy keys remain readable for older artifacts).
130
+ *
35
131
  * @param {string[]} providers
36
132
  * @param {string} zipPath
37
133
  * @param {import('../core/config.js').DeployHubConfig} config
38
134
  */
39
135
  export async function uploadToAll(providers, zipPath, config) {
40
136
  const log = createLogger('storage');
41
- const remoteKey = `${config.project}/v${config.version}/artifact.zip`;
137
+ ensureBuildIdentity(config);
138
+
139
+ const buildId = /** @type {string} */ (config.buildId);
140
+ const remoteKey = buildArtifactRemoteKey(config.project, buildId);
141
+ const latestKey = latestArtifactRemoteKey(config.project);
142
+ const entry = {
143
+ buildId,
144
+ semver: String(config.version || '0.0.0').replace(/^v/i, ''),
145
+ uploadedAt: new Date().toISOString(),
146
+ remoteKey,
147
+ };
42
148
 
43
149
  const uploads = providers.map(async (name) => {
44
150
  try {
45
151
  const provider = getStorageProvider(name);
46
- log.info(`Uploading to ${name}...`);
152
+ log.info(`Uploading build ${buildId} to ${name}...`);
47
153
  await provider.upload(zipPath, remoteKey);
48
- log.success(`Uploaded to ${name}`);
49
- return { name, success: true };
154
+ // Intentional overwrite: mutable "current" pointer, not a versioned backup.
155
+ await provider.upload(zipPath, latestKey);
156
+
157
+ let history = [];
158
+ try {
159
+ const histKey = historyRemoteKey(config.project);
160
+ if (await provider.verify(histKey)) {
161
+ const tmp = path.join(os.tmpdir(), `deployhub-hist-${name}-${Date.now()}.json`);
162
+ try {
163
+ await provider.download(histKey, tmp);
164
+ history = parseArtifactHistory(await fs.readFile(tmp, 'utf8'));
165
+ } finally {
166
+ await fs.remove(tmp).catch(() => {});
167
+ }
168
+ }
169
+ } catch {
170
+ history = [];
171
+ }
172
+
173
+ const next = prependHistoryEntry(history, entry);
174
+ await writeArtifactHistory(provider, config.project, next);
175
+
176
+ log.success(`Uploaded to ${name} (${remoteKey})`);
177
+ return { name, success: true, remoteKey, buildId };
50
178
  } catch (err) {
51
179
  const message = err instanceof Error ? err.message : String(err);
52
180
  throw new Error(`Storage upload to ${name} failed: ${message}`);
@@ -73,6 +201,27 @@ export async function downloadFromFirst(providers, remoteKey, localPath) {
73
201
  throw new Error(`Artifact not found in any configured storage provider`);
74
202
  }
75
203
 
204
+ /**
205
+ * Download a build by history entry, with legacy key fallback for pre-buildId uploads.
206
+ *
207
+ * @param {string[]} providers
208
+ * @param {import('../core/config.js').DeployHubConfig} config
209
+ * @param {{ remoteKey: string, buildId: string, semver: string }} entry
210
+ * @param {string} localPath
211
+ */
212
+ export async function downloadArtifactEntry(providers, config, entry, localPath) {
213
+ try {
214
+ return await downloadFromFirst(providers, entry.remoteKey, localPath);
215
+ } catch {
216
+ const legacyKey = legacyArtifactRemoteKey(config.project, entry.semver);
217
+ const log = createLogger('storage');
218
+ log.warn(
219
+ `Build key not found; trying legacy key ${legacyKey} (may be an overwritten single-slot artifact)`
220
+ );
221
+ return downloadFromFirst(providers, legacyKey, localPath);
222
+ }
223
+ }
224
+
76
225
  /**
77
226
  * @param {string} name
78
227
  */
@@ -99,11 +248,18 @@ export async function testAllProviders(providers) {
99
248
  }
100
249
  return {
101
250
  name,
102
- status: 'error',
103
- error: result.reason?.message || String(result.reason),
251
+ status: 'failed',
252
+ error: result.reason instanceof Error ? result.reason.message : String(result.reason),
104
253
  };
105
254
  });
106
255
  }
107
256
 
108
- export { PROVIDER_FACTORIES };
109
- export default { getStorageProvider, uploadToAll, downloadFromFirst, testProvider, testAllProviders };
257
+ export default {
258
+ getStorageProvider,
259
+ uploadToAll,
260
+ downloadFromFirst,
261
+ downloadArtifactEntry,
262
+ loadArtifactHistory,
263
+ testProvider,
264
+ testAllProviders,
265
+ };
@@ -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