@akash-chowdhury-24/deployhub 2.0.14 → 2.0.16

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 path from 'path';
2
2
  import fs from 'fs-extra';
3
3
  import os from 'os';
4
+ import { randomUUID } from 'crypto';
4
5
  import { createLogger } from '../logger/index.js';
5
6
  import { createAwsProvider } from './providers/aws.js';
6
7
  import { createLocalProvider } from './providers/local.js';
@@ -11,6 +12,8 @@ import { createDropboxProvider } from './providers/dropbox.js';
11
12
  import { createFtpProvider } from './providers/ftp.js';
12
13
  import {
13
14
  buildArtifactRemoteKey,
15
+ envHistoryRemoteKey,
16
+ envLatestArtifactRemoteKey,
14
17
  historyRemoteKey,
15
18
  latestArtifactRemoteKey,
16
19
  legacyArtifactRemoteKey,
@@ -22,6 +25,16 @@ import {
22
25
  } from '../utils/artifact-history.js';
23
26
  import { summarizeStorageError } from './storage-errors.js';
24
27
 
28
+ /**
29
+ * Unique temp path — never Date.now() alone (collides across concurrent
30
+ * processes / Jest workers in the same millisecond and corrupts history reads).
31
+ * @param {string} prefix
32
+ * @param {string} [ext]
33
+ */
34
+ function uniqueTmpPath(prefix, ext = '.json') {
35
+ return path.join(os.tmpdir(), `${prefix}-${randomUUID()}${ext}`);
36
+ }
37
+
25
38
  /** @type {Record<string, (env?: Record<string, string>) => ReturnType<typeof createAwsProvider>>} */
26
39
  const PROVIDER_FACTORIES = {
27
40
  aws: createAwsProvider,
@@ -77,7 +90,7 @@ export async function loadArtifactHistory(providers, project) {
77
90
  }
78
91
 
79
92
  const key = historyRemoteKey(project);
80
- const tmp = path.join(os.tmpdir(), `deployhub-history-${Date.now()}.json`);
93
+ const tmp = uniqueTmpPath('deployhub-history');
81
94
 
82
95
  try {
83
96
  for (const name of providers) {
@@ -114,7 +127,24 @@ export async function loadArtifactHistory(providers, project) {
114
127
  */
115
128
  async function writeArtifactHistory(provider, project, history) {
116
129
  const key = historyRemoteKey(project);
117
- const tmp = path.join(os.tmpdir(), `deployhub-history-write-${Date.now()}.json`);
130
+ const tmp = uniqueTmpPath('deployhub-history-write');
131
+ await fs.writeJson(tmp, history, { spaces: 2 });
132
+ try {
133
+ await provider.upload(tmp, key);
134
+ } finally {
135
+ await fs.remove(tmp).catch(() => {});
136
+ }
137
+ }
138
+
139
+ /**
140
+ * @param {ReturnType<typeof getStorageProvider>} provider
141
+ * @param {string} project
142
+ * @param {string} envName
143
+ * @param {import('../utils/artifact-history.js').ArtifactHistoryEntry[]} history
144
+ */
145
+ async function writeEnvArtifactHistory(provider, project, envName, history) {
146
+ const key = envHistoryRemoteKey(project, envName);
147
+ const tmp = uniqueTmpPath('deployhub-env-hist-write');
118
148
  await fs.writeJson(tmp, history, { spaces: 2 });
119
149
  try {
120
150
  await provider.upload(tmp, key);
@@ -123,6 +153,200 @@ async function writeArtifactHistory(provider, project, history) {
123
153
  }
124
154
  }
125
155
 
156
+ function legacyHistoryCopiedMarkerKey(project) {
157
+ return `${project}/.legacy-env-history-copied`;
158
+ }
159
+
160
+ /**
161
+ * Record that legacy history migration was handled (copy attempted or not needed).
162
+ * Prevents later rollback from copying the build catalog into the default env.
163
+ *
164
+ * @param {string[]} providers
165
+ * @param {string} project
166
+ */
167
+ async function markLegacyHistoryMigrationHandled(providers, project) {
168
+ if (!providers?.length || !project) return;
169
+ for (const name of providers) {
170
+ const provider = getStorageProvider(name);
171
+ if (await provider.verify(legacyHistoryCopiedMarkerKey(project))) {
172
+ return;
173
+ }
174
+ const markerTmp = uniqueTmpPath('deployhub-legacy-copied', '.txt');
175
+ await fs.writeFile(markerTmp, '1');
176
+ try {
177
+ await provider.upload(markerTmp, legacyHistoryCopiedMarkerKey(project));
178
+ } finally {
179
+ await fs.remove(markerTmp).catch(() => {});
180
+ }
181
+ }
182
+ }
183
+
184
+ /**
185
+ * One-time copy of legacy `{project}/history.json` into the default environment's
186
+ * `envs/{default}/history.json`. Separate from `recordEnvDeployment` (which never
187
+ * seeds from the project build catalog).
188
+ *
189
+ * Guard scope (judgment call — see comment in source):
190
+ * Only skips when the default env's own history key already exists, or when migration
191
+ * was already handled (storage marker / config.legacyHistoryMigrated). We do NOT skip
192
+ * just because a sibling env (e.g. testing) has history — that would lock default out
193
+ * of recovering real pre-multi-env deploy history after shape migration.
194
+ *
195
+ * @param {string[]} providers
196
+ * @param {string} project
197
+ * @param {string} defaultEnvironment
198
+ * @param {{ legacyHistoryMigrated?: boolean }} [options]
199
+ * @returns {Promise<boolean>} true when a copy was written
200
+ */
201
+ export async function ensureLegacyHistoryCopiedToDefaultEnv(
202
+ providers,
203
+ project,
204
+ defaultEnvironment,
205
+ options = {}
206
+ ) {
207
+ if (!providers?.length || !project || !defaultEnvironment) return false;
208
+ if (options.legacyHistoryMigrated === true) return false;
209
+
210
+ for (const name of providers) {
211
+ const provider = getStorageProvider(name);
212
+ if (await provider.verify(legacyHistoryCopiedMarkerKey(project))) {
213
+ return false;
214
+ }
215
+ }
216
+
217
+ const envKey = envHistoryRemoteKey(project, defaultEnvironment);
218
+ for (const name of providers) {
219
+ const provider = getStorageProvider(name);
220
+ if (await provider.verify(envKey)) {
221
+ return false;
222
+ }
223
+ }
224
+
225
+ const legacy = await loadArtifactHistory(providers, project);
226
+ if (legacy.entries.length === 0) {
227
+ await markLegacyHistoryMigrationHandled(providers, project);
228
+ return false;
229
+ }
230
+
231
+ const log = createLogger('storage');
232
+ log.info(
233
+ `Migrating legacy deploy history into env "${defaultEnvironment}" (${legacy.entries.length} entries)...`
234
+ );
235
+
236
+ for (const name of providers) {
237
+ const provider = getStorageProvider(name);
238
+ await writeEnvArtifactHistory(provider, project, defaultEnvironment, legacy.entries);
239
+ }
240
+ await markLegacyHistoryMigrationHandled(providers, project);
241
+ return true;
242
+ }
243
+
244
+ /**
245
+ * Load per-environment deploy history.
246
+ * Legacy project-wide `{project}/history.json` is NOT merged here during rollback
247
+ * (use `ensureLegacyHistoryCopiedToDefaultEnv` first). Read-only legacy fallback for
248
+ * list/doctor when `allowLegacyFallback` is true.
249
+ *
250
+ * @param {string[]} providers
251
+ * @param {string} project
252
+ * @param {string} envName
253
+ * @param {{ defaultEnvironment?: string|null, allowLegacyFallback?: boolean }} [options]
254
+ */
255
+ export async function loadEnvArtifactHistory(providers, project, envName, options = {}) {
256
+ if (!providers || providers.length === 0) {
257
+ return { entries: [], source: null, key: envHistoryRemoteKey(project, envName) };
258
+ }
259
+
260
+ const envKey = envHistoryRemoteKey(project, envName);
261
+ const tmp = uniqueTmpPath('deployhub-env-history');
262
+
263
+ try {
264
+ for (const name of providers) {
265
+ try {
266
+ const provider = getStorageProvider(name);
267
+ const exists = await provider.verify(envKey);
268
+ if (!exists) continue;
269
+
270
+ await provider.download(envKey, tmp);
271
+ const raw = await fs.readFile(tmp, 'utf8');
272
+ return {
273
+ entries: parseArtifactHistory(raw),
274
+ source: name,
275
+ key: envKey,
276
+ };
277
+ } catch (err) {
278
+ const reason = summarizeStorageError(err);
279
+ throw new Error(
280
+ `Could not check remote history via ${name}: ${reason} — ` +
281
+ 'verify your storage credentials and configuration are correct.'
282
+ );
283
+ }
284
+ }
285
+ } finally {
286
+ await fs.remove(tmp).catch(() => {});
287
+ }
288
+
289
+ const defaultName =
290
+ typeof options.defaultEnvironment === 'string' ? options.defaultEnvironment : null;
291
+ const allowLegacy =
292
+ options.allowLegacyFallback !== false &&
293
+ (envName === 'default' || (defaultName !== null && envName === defaultName));
294
+
295
+ if (allowLegacy) {
296
+ const legacy = await loadArtifactHistory(providers, project);
297
+ if (legacy.entries.length > 0) {
298
+ return {
299
+ entries: legacy.entries,
300
+ source: legacy.source,
301
+ key: historyRemoteKey(project),
302
+ legacyFallback: true,
303
+ };
304
+ }
305
+ }
306
+
307
+ return { entries: [], source: null, key: envKey };
308
+ }
309
+
310
+ /**
311
+ * Record a successful deploy into per-env history + env latest pointer.
312
+ * Does not touch other environments' history.
313
+ *
314
+ * @param {string[]} providers
315
+ * @param {string} zipPath path to artifact.zip to copy into envs/{env}/latest/
316
+ * @param {import('../core/config.js').DeployHubConfig} config
317
+ * @param {string} envName
318
+ * @param {{ buildId: string, semver?: string, remoteKey?: string }} meta
319
+ */
320
+ export async function recordEnvDeployment(providers, zipPath, config, envName, meta) {
321
+ const log = createLogger('storage');
322
+ const buildId = meta.buildId;
323
+ const remoteKey =
324
+ meta.remoteKey || buildArtifactRemoteKey(config.project, buildId);
325
+ const entry = {
326
+ buildId,
327
+ semver: String(meta.semver || config.version || '0.0.0').replace(/^v/i, ''),
328
+ uploadedAt: new Date().toISOString(),
329
+ remoteKey,
330
+ };
331
+ const envLatestKey = envLatestArtifactRemoteKey(config.project, envName);
332
+
333
+ for (const name of providers) {
334
+ const provider = getStorageProvider(name);
335
+ log.info(`Recording deploy of ${buildId} to env "${envName}" on ${name}...`);
336
+
337
+ if (zipPath && (await fs.pathExists(zipPath))) {
338
+ await provider.upload(zipPath, envLatestKey);
339
+ }
340
+
341
+ const loaded = await loadEnvArtifactHistory([name], config.project, envName, {
342
+ // Never seed deploy history from the project-wide build catalog on record.
343
+ allowLegacyFallback: false,
344
+ });
345
+ const next = prependHistoryEntry(loaded.entries, entry);
346
+ await writeEnvArtifactHistory(provider, config.project, envName, next);
347
+ }
348
+ }
349
+
126
350
  /**
127
351
  * Upload artifact zip under a unique build key, update latest/ pointer and history.json.
128
352
  * Does NOT write legacy `{project}/v{semver}/artifact.zip` (retired for new uploads;
@@ -158,7 +382,7 @@ export async function uploadToAll(providers, zipPath, config) {
158
382
  try {
159
383
  const histKey = historyRemoteKey(config.project);
160
384
  if (await provider.verify(histKey)) {
161
- const tmp = path.join(os.tmpdir(), `deployhub-hist-${name}-${Date.now()}.json`);
385
+ const tmp = uniqueTmpPath(`deployhub-hist-${name}`);
162
386
  try {
163
387
  await provider.download(histKey, tmp);
164
388
  history = parseArtifactHistory(await fs.readFile(tmp, 'utf8'));
@@ -260,6 +484,9 @@ export default {
260
484
  downloadFromFirst,
261
485
  downloadArtifactEntry,
262
486
  loadArtifactHistory,
487
+ loadEnvArtifactHistory,
488
+ ensureLegacyHistoryCopiedToDefaultEnv,
489
+ recordEnvDeployment,
263
490
  testProvider,
264
491
  testAllProviders,
265
492
  };
@@ -122,12 +122,33 @@ export function latestArtifactRemoteKey(project) {
122
122
  }
123
123
 
124
124
  /**
125
+ * Project-wide build catalog (newest-first). Legacy pre-multi-env history;
126
+ * also still written on upload as the available-builds index for `artifact list --remote`.
127
+ * Per-environment deploy history lives under envs/{env}/history.json.
125
128
  * @param {string} project
126
129
  */
127
130
  export function historyRemoteKey(project) {
128
131
  return `${project}/history.json`;
129
132
  }
130
133
 
134
+ /**
135
+ * Per-environment deploy history (newest-first). Rollback resolves against this only.
136
+ * @param {string} project
137
+ * @param {string} envName
138
+ */
139
+ export function envHistoryRemoteKey(project, envName) {
140
+ return `${project}/envs/${sanitizeBuildIdPart(envName)}/history.json`;
141
+ }
142
+
143
+ /**
144
+ * Per-environment "currently deployed" pointer (NOT a backup — same semantics as project latest/).
145
+ * @param {string} project
146
+ * @param {string} envName
147
+ */
148
+ export function envLatestArtifactRemoteKey(project, envName) {
149
+ return `${project}/envs/${sanitizeBuildIdPart(envName)}/latest/artifact.zip`;
150
+ }
151
+
131
152
  /**
132
153
  * Pre-buildId legacy key (read-only fallback; never written by new uploads).
133
154
  * @param {string} project
@@ -145,5 +166,7 @@ export default {
145
166
  buildArtifactRemoteKey,
146
167
  latestArtifactRemoteKey,
147
168
  historyRemoteKey,
169
+ envHistoryRemoteKey,
170
+ envLatestArtifactRemoteKey,
148
171
  legacyArtifactRemoteKey,
149
172
  };
@@ -0,0 +1,117 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+
4
+ /** Static SPA frameworks that use nginx:alpine + EXPOSE 80 in generated Dockerfiles. */
5
+ const STATIC_FRONTEND_FRAMEWORKS = new Set([
6
+ 'react',
7
+ 'vue',
8
+ 'angular',
9
+ 'svelte',
10
+ 'astro',
11
+ 'vanilla',
12
+ ]);
13
+
14
+ /**
15
+ * Parse the listening port from Dockerfile EXPOSE instructions.
16
+ * Uses the last EXPOSE line (final stage in multi-stage builds).
17
+ * Supports `EXPOSE 80`, `EXPOSE 80/tcp`, and multi-port lines (first numeric wins on that line).
18
+ *
19
+ * @param {string} content
20
+ * @returns {number|null}
21
+ */
22
+ export function parseDockerfileExposePort(content) {
23
+ if (!content || typeof content !== 'string') return null;
24
+
25
+ /** @type {number|null} */
26
+ let lastPort = null;
27
+
28
+ for (const rawLine of content.split(/\r?\n/)) {
29
+ const line = rawLine.trim();
30
+ if (!line || line.startsWith('#')) continue;
31
+
32
+ const match = line.match(/^EXPOSE\s+(.+)$/i);
33
+ if (!match) continue;
34
+
35
+ const tokens = match[1].trim().split(/\s+/);
36
+ for (const token of tokens) {
37
+ const portToken = token.split('/')[0];
38
+ if (!/^\d+$/.test(portToken)) continue;
39
+ const port = Number(portToken);
40
+ if (port >= 1 && port <= 65535) {
41
+ lastPort = port;
42
+ break;
43
+ }
44
+ }
45
+ }
46
+
47
+ return lastPort;
48
+ }
49
+
50
+ /**
51
+ * Per-type fallback matching generated Dockerfile templates when EXPOSE is unavailable.
52
+ *
53
+ * @param {import('../core/config.js').DeployHubConfig} config
54
+ * @returns {number}
55
+ */
56
+ export function resolveFallbackContainerPort(config) {
57
+ const projectType = config.projectType || 'frontend';
58
+ const framework =
59
+ (projectType === 'both'
60
+ ? config.backend?.framework || config.framework
61
+ : config.framework) ||
62
+ (projectType === 'frontend' ? 'react' : 'express');
63
+
64
+ if (projectType === 'frontend' && STATIC_FRONTEND_FRAMEWORKS.has(framework)) {
65
+ return 80;
66
+ }
67
+
68
+ if (['laravel', 'symfony', 'php'].includes(framework)) return 80;
69
+ if (['fastapi', 'django', 'flask', 'python'].includes(framework)) return 8000;
70
+ if (['spring', 'java'].includes(framework)) return 8080;
71
+ if (framework === 'go') return 8080;
72
+ if (framework === 'dotnet') return 5000;
73
+ if (framework === 'rails') return 3000;
74
+
75
+ // nextjs, nestjs, express, and other Node backends
76
+ return 3000;
77
+ }
78
+
79
+ /**
80
+ * Precedence: Dockerfile EXPOSE → config.port / backend.port → per-type fallback.
81
+ *
82
+ * @param {string} cwd
83
+ * @param {import('../core/config.js').DeployHubConfig} config
84
+ * @returns {Promise<{ port: number, source: 'expose'|'config'|'fallback' }>}
85
+ */
86
+ export async function resolveContainerPort(cwd, config) {
87
+ const dockerfilePath = path.join(cwd, 'Dockerfile');
88
+ if (await fs.pathExists(dockerfilePath)) {
89
+ try {
90
+ const content = await fs.readFile(dockerfilePath, 'utf8');
91
+ const exposed = parseDockerfileExposePort(content);
92
+ if (exposed != null) {
93
+ return { port: exposed, source: 'expose' };
94
+ }
95
+ } catch {
96
+ // treat as unparseable / unreadable → continue
97
+ }
98
+ }
99
+
100
+ if (config.projectType === 'both' && config.backend?.port) {
101
+ return { port: Number(config.backend.port), source: 'config' };
102
+ }
103
+ if (config.port) {
104
+ return { port: Number(config.port), source: 'config' };
105
+ }
106
+ if (config.backend?.port) {
107
+ return { port: Number(config.backend.port), source: 'config' };
108
+ }
109
+
110
+ return { port: resolveFallbackContainerPort(config), source: 'fallback' };
111
+ }
112
+
113
+ export default {
114
+ parseDockerfileExposePort,
115
+ resolveFallbackContainerPort,
116
+ resolveContainerPort,
117
+ };