@akash-chowdhury-24/deployhub 1.0.0

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.
Files changed (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +176 -0
  3. package/install.ps1 +55 -0
  4. package/install.sh +99 -0
  5. package/package.json +86 -0
  6. package/src/adapters/dotnet.adapter.js +41 -0
  7. package/src/adapters/go.adapter.js +40 -0
  8. package/src/adapters/index.js +48 -0
  9. package/src/adapters/java.adapter.js +46 -0
  10. package/src/adapters/node.adapter.js +77 -0
  11. package/src/adapters/php.adapter.js +43 -0
  12. package/src/adapters/python.adapter.js +54 -0
  13. package/src/adapters/rails.adapter.js +66 -0
  14. package/src/artifact/engine.js +473 -0
  15. package/src/cli/index.js +45 -0
  16. package/src/commands/artifact.js +88 -0
  17. package/src/commands/build.js +44 -0
  18. package/src/commands/clean.js +50 -0
  19. package/src/commands/deploy.js +82 -0
  20. package/src/commands/doctor.js +630 -0
  21. package/src/commands/init.js +795 -0
  22. package/src/commands/logs.js +33 -0
  23. package/src/commands/rollback.js +50 -0
  24. package/src/commands/storage.js +116 -0
  25. package/src/commands/update.js +63 -0
  26. package/src/commands/verify.js +55 -0
  27. package/src/core/config.js +168 -0
  28. package/src/core/pipeline.js +77 -0
  29. package/src/core/stages.js +210 -0
  30. package/src/deployment/index.js +156 -0
  31. package/src/deployment/providers/azure-vm.js +7 -0
  32. package/src/deployment/providers/docker.js +30 -0
  33. package/src/deployment/providers/ec2.js +7 -0
  34. package/src/deployment/providers/gcp-vm.js +7 -0
  35. package/src/deployment/providers/kubernetes.js +30 -0
  36. package/src/deployment/providers/platforms/_shared.js +167 -0
  37. package/src/deployment/providers/platforms/aws-amplify.js +164 -0
  38. package/src/deployment/providers/platforms/azure-static-web-apps.js +68 -0
  39. package/src/deployment/providers/platforms/cloudflare-pages.js +103 -0
  40. package/src/deployment/providers/platforms/firebase-app-hosting.js +95 -0
  41. package/src/deployment/providers/platforms/firebase-hosting.js +99 -0
  42. package/src/deployment/providers/platforms/index.js +44 -0
  43. package/src/deployment/providers/platforms/netlify.js +102 -0
  44. package/src/deployment/providers/platforms/vercel.js +92 -0
  45. package/src/deployment/providers/ssh.js +365 -0
  46. package/src/detectors/angular.js +23 -0
  47. package/src/detectors/backend.detector.js +304 -0
  48. package/src/detectors/dotnet.js +18 -0
  49. package/src/detectors/frontend.detector.js +219 -0
  50. package/src/detectors/go.js +20 -0
  51. package/src/detectors/index.js +78 -0
  52. package/src/detectors/java.js +24 -0
  53. package/src/detectors/nextjs.js +23 -0
  54. package/src/detectors/node.js +28 -0
  55. package/src/detectors/php.js +17 -0
  56. package/src/detectors/python.js +22 -0
  57. package/src/detectors/react.js +29 -0
  58. package/src/detectors/vue.js +23 -0
  59. package/src/logger/index.js +44 -0
  60. package/src/notifications/email.js +53 -0
  61. package/src/notifications/index.js +34 -0
  62. package/src/notifications/slack.js +18 -0
  63. package/src/notifications/webhook.js +21 -0
  64. package/src/rollback/engine.js +102 -0
  65. package/src/storage/index.js +109 -0
  66. package/src/storage/providers/aws.js +96 -0
  67. package/src/storage/providers/azure.js +45 -0
  68. package/src/storage/providers/dropbox.js +49 -0
  69. package/src/storage/providers/ftp.js +69 -0
  70. package/src/storage/providers/gcp.js +45 -0
  71. package/src/storage/providers/gdrive.js +80 -0
  72. package/src/storage/providers/local.js +61 -0
  73. package/src/utils/author.js +141 -0
  74. package/src/utils/checksums.js +53 -0
  75. package/src/utils/firebase-config-generator.js +35 -0
  76. package/src/utils/github-actions.js +389 -0
  77. package/src/utils/init-platform.js +229 -0
  78. package/src/utils/nginx.js +34 -0
  79. package/src/utils/platform-env.js +132 -0
  80. package/src/utils/version.js +31 -0
@@ -0,0 +1,43 @@
1
+ import { execa } from 'execa';
2
+ import fs from 'fs-extra';
3
+ import path from 'path';
4
+ import { createLogger } from '../logger/index.js';
5
+
6
+ function create(config, cwd) {
7
+ const log = createLogger('php');
8
+
9
+ return {
10
+ detect() {
11
+ return fs.existsSync(path.join(cwd, 'composer.json'));
12
+ },
13
+
14
+ async install() {
15
+ log.info('Running composer install...');
16
+ await execa('composer', ['install', '--no-dev'], {
17
+ cwd,
18
+ stdio: 'inherit',
19
+ });
20
+ },
21
+
22
+ async test() {
23
+ log.warn('PHP tests not configured, skipping');
24
+ },
25
+
26
+ async build() {
27
+ log.info(`Running: ${config.buildCommand}`);
28
+ const [cmd, ...args] = config.buildCommand.split(' ');
29
+ await execa(cmd, args, { cwd, stdio: 'inherit', shell: true });
30
+ },
31
+
32
+ async docker() {
33
+ if (await fs.pathExists(path.join(cwd, 'Dockerfile'))) {
34
+ await execa('docker', ['build', '-t', `${config.project}:latest`, '.'], {
35
+ cwd,
36
+ stdio: 'inherit',
37
+ });
38
+ }
39
+ },
40
+ };
41
+ }
42
+
43
+ export default { create };
@@ -0,0 +1,54 @@
1
+ import { execa } from 'execa';
2
+ import fs from 'fs-extra';
3
+ import path from 'path';
4
+ import { createLogger } from '../logger/index.js';
5
+
6
+ function create(config, cwd) {
7
+ const log = createLogger('python');
8
+
9
+ return {
10
+ detect() {
11
+ return (
12
+ fs.existsSync(path.join(cwd, 'requirements.txt')) ||
13
+ fs.existsSync(path.join(cwd, 'pyproject.toml'))
14
+ );
15
+ },
16
+
17
+ async install() {
18
+ log.info('Installing Python dependencies...');
19
+ if (fs.existsSync(path.join(cwd, 'requirements.txt'))) {
20
+ await execa('pip', ['install', '-r', 'requirements.txt'], {
21
+ cwd,
22
+ stdio: 'inherit',
23
+ });
24
+ }
25
+ },
26
+
27
+ async test() {
28
+ if (fs.existsSync(path.join(cwd, 'pytest.ini'))) {
29
+ await execa('pytest', [], { cwd, stdio: 'inherit' });
30
+ } else {
31
+ log.warn('No pytest config found, skipping tests');
32
+ }
33
+ },
34
+
35
+ async build() {
36
+ log.info(`Running: ${config.buildCommand}`);
37
+ const [cmd, ...args] = config.buildCommand.split(' ');
38
+ await execa(cmd, args, { cwd, stdio: 'inherit', shell: true });
39
+ },
40
+
41
+ async docker() {
42
+ if (!(await fs.pathExists(path.join(cwd, 'Dockerfile')))) {
43
+ log.warn('No Dockerfile found, skipping');
44
+ return;
45
+ }
46
+ await execa('docker', ['build', '-t', `${config.project}:latest`, '.'], {
47
+ cwd,
48
+ stdio: 'inherit',
49
+ });
50
+ },
51
+ };
52
+ }
53
+
54
+ export default { create };
@@ -0,0 +1,66 @@
1
+ import { execa } from 'execa';
2
+ import fs from 'fs-extra';
3
+ import path from 'path';
4
+ import { createLogger } from '../logger/index.js';
5
+
6
+ /**
7
+ * @param {import('../core/config.js').DeployHubConfig} config
8
+ * @param {string} cwd
9
+ */
10
+ function create(config, cwd) {
11
+ const log = createLogger('rails');
12
+
13
+ return {
14
+ detect() {
15
+ return (
16
+ fs.existsSync(path.join(cwd, 'Gemfile')) &&
17
+ (fs.existsSync(path.join(cwd, 'config.ru')) ||
18
+ fs.existsSync(path.join(cwd, 'config', 'application.rb')))
19
+ );
20
+ },
21
+
22
+ async install() {
23
+ log.info('Installing Ruby dependencies...');
24
+ await execa('bundle', ['install'], { cwd, stdio: 'inherit' });
25
+ },
26
+
27
+ async test() {
28
+ if (await fs.pathExists(path.join(cwd, 'spec'))) {
29
+ log.info('Running RSpec tests...');
30
+ await execa('bundle', ['exec', 'rspec'], { cwd, stdio: 'inherit' });
31
+ return;
32
+ }
33
+ if (await fs.pathExists(path.join(cwd, 'test'))) {
34
+ log.info('Running Rails tests...');
35
+ await execa('bundle', ['exec', 'rails', 'test'], { cwd, stdio: 'inherit' });
36
+ return;
37
+ }
38
+ log.warn('No spec/ or test/ directory found, skipping tests');
39
+ },
40
+
41
+ async build() {
42
+ const buildCommand = config.buildCommand || 'bundle exec rails assets:precompile';
43
+ if (!buildCommand) {
44
+ log.info('No build command configured, skipping');
45
+ return;
46
+ }
47
+ log.info(`Running build: ${buildCommand}`);
48
+ const [cmd, ...args] = buildCommand.split(' ');
49
+ await execa(cmd, args, { cwd, stdio: 'inherit', shell: true });
50
+ },
51
+
52
+ async docker() {
53
+ if (!(await fs.pathExists(path.join(cwd, 'Dockerfile')))) {
54
+ log.warn('No Dockerfile found, skipping docker build');
55
+ return;
56
+ }
57
+ log.info('Building Docker image...');
58
+ await execa('docker', ['build', '-t', `${config.project}:latest`, '.'], {
59
+ cwd,
60
+ stdio: 'inherit',
61
+ });
62
+ },
63
+ };
64
+ }
65
+
66
+ export default { create };
@@ -0,0 +1,473 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import archiver from 'archiver';
4
+ import { execa } from 'execa';
5
+ import { createLogger } from '../logger/index.js';
6
+ import { generateChecksums, formatChecksums } from '../utils/checksums.js';
7
+ import { getProjectVersion } from '../utils/version.js';
8
+ import { generateNginxConfig } from '../utils/nginx.js';
9
+ import { getGeneratedByMetadata, getArtifactReadmeFooter } from '../utils/author.js';
10
+
11
+ /**
12
+ * @param {string} cwd
13
+ * @returns {Promise<{ commit: string, branch: string }>}
14
+ */
15
+ async function getGitInfo(cwd) {
16
+ try {
17
+ const { stdout: commit } = await execa('git', ['rev-parse', '--short', 'HEAD'], {
18
+ cwd,
19
+ });
20
+ const { stdout: branch } = await execa('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
21
+ cwd,
22
+ });
23
+ return { commit: commit.trim(), branch: branch.trim() };
24
+ } catch {
25
+ return { commit: 'unknown', branch: 'unknown' };
26
+ }
27
+ }
28
+
29
+ /**
30
+ * @param {string} cwd
31
+ * @param {number} [count]
32
+ * @returns {Promise<string>}
33
+ */
34
+ async function getReleaseNotes(cwd, count = 10) {
35
+ try {
36
+ const { stdout } = await execa(
37
+ 'git',
38
+ ['log', `-${count}`, '--pretty=format:- %s (%h)'],
39
+ { cwd }
40
+ );
41
+ return `# Release Notes\n\n${stdout}\n`;
42
+ } catch {
43
+ return '# Release Notes\n\nNo git history available.\n';
44
+ }
45
+ }
46
+
47
+ /**
48
+ * @param {import('../core/config.js').DeployHubConfig} config
49
+ * @returns {'frontend'|'backend'}
50
+ */
51
+ function resolveArtifactType(config) {
52
+ if (config.projectType === 'both') return 'backend';
53
+ return config.projectType === 'backend' ? 'backend' : 'frontend';
54
+ }
55
+
56
+ /**
57
+ * @param {import('../core/config.js').DeployHubConfig} config
58
+ * @returns {{ buildOutput: string, framework: string, startCommand: string|null, port: number, buildCommand: string|null }}
59
+ */
60
+ function resolveBuildSettings(config) {
61
+ if (config.projectType === 'both' && config.backend) {
62
+ return {
63
+ buildOutput: config.backend.buildOutput || '.',
64
+ framework: config.backend.framework,
65
+ startCommand: config.backend.startCommand || null,
66
+ port: config.backend.port || 3000,
67
+ buildCommand: config.backend.buildCommand ?? null,
68
+ };
69
+ }
70
+
71
+ return {
72
+ buildOutput: config.buildOutput || 'dist',
73
+ framework: config.framework || 'node',
74
+ startCommand: config.startCommand || null,
75
+ port: config.port || 3000,
76
+ buildCommand: config.buildCommand ?? null,
77
+ };
78
+ }
79
+
80
+ /**
81
+ * @param {import('../core/config.js').DeployHubConfig} config
82
+ * @returns {{ buildOutput: string, framework: string }}
83
+ */
84
+ function resolveFrontendSettings(config) {
85
+ if (config.projectType === 'both' && config.frontend) {
86
+ return {
87
+ buildOutput: config.frontend.buildOutput || 'dist',
88
+ framework: config.frontend.framework,
89
+ buildCommand: config.frontend.buildCommand ?? null,
90
+ };
91
+ }
92
+
93
+ return {
94
+ buildOutput: config.buildOutput || 'dist',
95
+ framework: config.framework || 'react',
96
+ buildCommand: config.buildCommand ?? null,
97
+ };
98
+ }
99
+
100
+ /**
101
+ * @param {string} srcDir
102
+ * @param {string} destDir
103
+ * @param {string} label
104
+ */
105
+ async function copyIfExists(srcDir, destDir, label) {
106
+ const src = path.join(srcDir, label);
107
+ if (await fs.pathExists(src)) {
108
+ await fs.copy(src, path.join(destDir, label));
109
+ }
110
+ }
111
+
112
+ /**
113
+ * @param {string} cwd
114
+ * @param {string} stagingDir
115
+ * @param {string} dirName
116
+ */
117
+ async function copyDirectoryIfExists(cwd, stagingDir, dirName) {
118
+ const src = path.join(cwd, dirName);
119
+ if (await fs.pathExists(src)) {
120
+ await fs.copy(src, path.join(stagingDir, dirName));
121
+ }
122
+ }
123
+
124
+ /**
125
+ * @param {string} cwd
126
+ * @param {string} stagingDir
127
+ * @param {import('../core/config.js').DeployHubConfig} config
128
+ */
129
+ async function stageFrontendArtifact(cwd, stagingDir, config) {
130
+ const frontend = resolveFrontendSettings(config);
131
+ const buildSrc = path.join(cwd, frontend.buildOutput);
132
+ const outputName = path.basename(frontend.buildOutput) || 'dist';
133
+
134
+ if (frontend.buildOutput === '.') {
135
+ if (await fs.pathExists(path.join(cwd, 'index.html'))) {
136
+ await fs.copy(path.join(cwd, 'index.html'), path.join(stagingDir, 'index.html'));
137
+ }
138
+ } else if (await fs.pathExists(buildSrc)) {
139
+ await fs.copy(buildSrc, path.join(stagingDir, outputName));
140
+ }
141
+
142
+ for (const file of ['Dockerfile', 'docker-compose.yml', 'package.json', 'nginx.conf']) {
143
+ await copyIfExists(cwd, stagingDir, file);
144
+ }
145
+
146
+ const hasSshDeploy = (config.deploy || []).some(
147
+ (envName) => config.environments[envName]?.type === 'ssh'
148
+ );
149
+
150
+ if (hasSshDeploy && !fs.existsSync(path.join(stagingDir, 'nginx.conf'))) {
151
+ const envName = config.deploy?.[0];
152
+ const env = envName ? config.environments[envName] : null;
153
+ const deployPath =
154
+ env?.frontendDeployPath || env?.deployPath || env?.path || `/var/www/${config.project}`;
155
+ const nginxConf = generateNginxConfig(config.project, deployPath, outputName);
156
+ await fs.writeFile(path.join(stagingDir, 'nginx.conf'), nginxConf);
157
+ }
158
+ }
159
+
160
+ /**
161
+ * @param {string} cwd
162
+ * @param {string} stagingDir
163
+ * @param {import('../core/config.js').DeployHubConfig} config
164
+ */
165
+ async function stageBackendArtifact(cwd, stagingDir, config) {
166
+ const settings = resolveBuildSettings(config);
167
+ const framework = settings.framework;
168
+
169
+ await copyDirectoryIfExists(cwd, stagingDir, 'src');
170
+
171
+ for (const file of ['Dockerfile', 'docker-compose.yml', '.env.example']) {
172
+ await copyIfExists(cwd, stagingDir, file);
173
+ }
174
+
175
+ await copyDirectoryIfExists(cwd, stagingDir, 'config');
176
+ await copyDirectoryIfExists(cwd, stagingDir, 'migrations');
177
+
178
+ if (['express', 'nestjs', 'fastify', 'koa', 'nextjs'].includes(framework)) {
179
+ await copyIfExists(cwd, stagingDir, 'package.json');
180
+ } else if (['fastapi', 'django', 'flask'].includes(framework)) {
181
+ await copyIfExists(cwd, stagingDir, 'requirements.txt');
182
+ if (framework === 'django' && (await fs.pathExists(path.join(cwd, 'manage.py')))) {
183
+ await copyIfExists(cwd, stagingDir, 'manage.py');
184
+ }
185
+ } else if (['laravel', 'symfony'].includes(framework)) {
186
+ await copyIfExists(cwd, stagingDir, 'composer.json');
187
+ await copyIfExists(cwd, stagingDir, 'composer.lock');
188
+ } else if (framework === 'spring') {
189
+ await copyIfExists(cwd, stagingDir, 'pom.xml');
190
+ const targetDir = path.join(cwd, 'target');
191
+ if (await fs.pathExists(targetDir)) {
192
+ await fs.ensureDir(path.join(stagingDir, 'target'));
193
+ const jars = (await fs.readdir(targetDir)).filter((f) => f.endsWith('.jar'));
194
+ for (const jar of jars) {
195
+ await fs.copy(path.join(targetDir, jar), path.join(stagingDir, 'target', jar));
196
+ }
197
+ }
198
+ } else if (framework === 'go') {
199
+ await copyIfExists(cwd, stagingDir, 'go.mod');
200
+ await copyIfExists(cwd, stagingDir, 'go.sum');
201
+ await copyDirectoryIfExists(cwd, stagingDir, 'bin');
202
+ } else if (framework === 'dotnet') {
203
+ const files = await fs.readdir(cwd);
204
+ for (const f of files.filter((name) => name.endsWith('.csproj'))) {
205
+ await copyIfExists(cwd, stagingDir, f);
206
+ }
207
+ await copyDirectoryIfExists(cwd, stagingDir, settings.buildOutput || 'publish');
208
+ } else if (framework === 'rails') {
209
+ await copyIfExists(cwd, stagingDir, 'Gemfile');
210
+ await copyIfExists(cwd, stagingDir, 'Gemfile.lock');
211
+ await copyIfExists(cwd, stagingDir, 'config.ru');
212
+ } else {
213
+ await copyIfExists(cwd, stagingDir, 'package.json');
214
+ await copyIfExists(cwd, stagingDir, 'requirements.txt');
215
+ }
216
+
217
+ if (settings.buildOutput && settings.buildOutput !== '.' && settings.buildOutput !== 'src') {
218
+ const built = path.join(cwd, settings.buildOutput);
219
+ if (await fs.pathExists(built) && !['target', 'bin', 'publish'].includes(settings.buildOutput)) {
220
+ await fs.copy(built, path.join(stagingDir, settings.buildOutput));
221
+ }
222
+ }
223
+ }
224
+
225
+ /**
226
+ * @param {import('../core/config.js').DeployHubConfig} config
227
+ * @param {string} [cwd]
228
+ * @returns {string}
229
+ */
230
+ export function getArtifactDir(config, cwd = process.cwd()) {
231
+ const date = new Date().toISOString().slice(0, 10);
232
+ return path.join(
233
+ cwd,
234
+ 'artifact',
235
+ config.project,
236
+ date,
237
+ `v${config.version}`
238
+ );
239
+ }
240
+
241
+ /**
242
+ * @param {import('../core/config.js').DeployHubConfig} config
243
+ * @param {string[]} [deployedTargets]
244
+ * @param {string} [cwd]
245
+ * @returns {Promise<{ artifactDir: string, zipPath: string }>}
246
+ */
247
+ export async function createArtifact(config, deployedTargets = [], cwd = process.cwd()) {
248
+ const log = createLogger('artifact');
249
+ const version = config.version || (await getProjectVersion(cwd));
250
+ config.version = version;
251
+
252
+ const artifactDir = getArtifactDir(config, cwd);
253
+ const stagingDir = path.join(artifactDir, '_staging');
254
+ await fs.emptyDir(stagingDir);
255
+
256
+ const projectType = config.projectType || 'frontend';
257
+ const artifactType = resolveArtifactType(config);
258
+ const settings = resolveBuildSettings(config);
259
+
260
+ log.info(`Staging ${projectType} artifact...`);
261
+
262
+ if (projectType === 'both') {
263
+ await stageFrontendArtifact(cwd, stagingDir, config);
264
+ const backendStaging = path.join(stagingDir, 'backend');
265
+ await fs.ensureDir(backendStaging);
266
+ await stageBackendArtifact(cwd, backendStaging, config);
267
+ } else if (artifactType === 'backend') {
268
+ await stageBackendArtifact(cwd, stagingDir, config);
269
+ } else {
270
+ await stageFrontendArtifact(cwd, stagingDir, config);
271
+ }
272
+
273
+ const git = await getGitInfo(cwd);
274
+ const environment = process.env.DEPLOYHUB_ENV || 'production';
275
+ const timestamp = new Date().toISOString();
276
+
277
+ const metadata = {
278
+ project: config.project,
279
+ version,
280
+ timestamp,
281
+ gitCommit: git.commit,
282
+ branch: git.branch,
283
+ environment,
284
+ projectType: artifactType,
285
+ framework: settings.framework,
286
+ buildOutput: settings.buildOutput,
287
+ startCommand: settings.startCommand,
288
+ port: settings.port,
289
+ generatedBy: getGeneratedByMetadata(),
290
+ };
291
+
292
+ if (projectType === 'both') {
293
+ const frontend = resolveFrontendSettings(config);
294
+ metadata.projectType = 'both';
295
+ metadata.frontend = frontend;
296
+ metadata.backend = {
297
+ framework: settings.framework,
298
+ startCommand: settings.startCommand,
299
+ port: settings.port,
300
+ };
301
+ }
302
+
303
+ const readme = `# ${config.project} Artifact v${version}
304
+
305
+ This artifact was created by DeployHub on ${timestamp}.
306
+ Project type: ${projectType}
307
+
308
+ ## Manual Re-deployment
309
+
310
+ 1. Extract \`artifact.zip\`
311
+ 2. Copy files to your server
312
+ 3. Follow framework-specific start instructions in deployment.json
313
+
314
+ No other tooling required — everything needed is in this archive.
315
+
316
+ ${getArtifactReadmeFooter()}`;
317
+
318
+ await fs.writeFile(path.join(stagingDir, 'README.md'), readme);
319
+ await fs.writeJson(path.join(stagingDir, 'metadata.json'), metadata, { spaces: 2 });
320
+ await fs.writeJson(path.join(artifactDir, 'metadata.json'), metadata, { spaces: 2 });
321
+ await fs.writeFile(
322
+ path.join(artifactDir, 'release-notes.md'),
323
+ await getReleaseNotes(cwd)
324
+ );
325
+ await fs.writeFile(path.join(artifactDir, 'README.md'), readme);
326
+ await fs.writeJson(
327
+ path.join(artifactDir, 'deployment.json'),
328
+ { targets: deployedTargets, deployedAt: timestamp },
329
+ { spaces: 2 }
330
+ );
331
+
332
+ const logsContent = `[${timestamp}] Artifact created for ${config.project} v${version} (${projectType})\n`;
333
+ await fs.writeFile(path.join(artifactDir, 'logs.txt'), logsContent);
334
+ await fs.writeFile(path.join(stagingDir, 'logs.txt'), logsContent);
335
+
336
+ log.info('Creating zip archive...');
337
+ const zipPath = path.join(artifactDir, 'artifact.zip');
338
+ await createZip(stagingDir, zipPath);
339
+
340
+ const checksums = await generateChecksums(stagingDir);
341
+ const checksumContent = formatChecksums(checksums);
342
+ await fs.writeFile(path.join(artifactDir, 'checksums.txt'), checksumContent);
343
+ await fs.writeFile(path.join(stagingDir, 'checksums.txt'), checksumContent);
344
+ await fs.writeJson(
345
+ path.join(stagingDir, 'deployment.json'),
346
+ { targets: deployedTargets, deployedAt: timestamp },
347
+ { spaces: 2 }
348
+ );
349
+ await fs.writeFile(
350
+ path.join(stagingDir, 'release-notes.md'),
351
+ await getReleaseNotes(cwd)
352
+ );
353
+
354
+ await fs.remove(stagingDir);
355
+ log.success(`Artifact created at ${artifactDir}`);
356
+
357
+ return { artifactDir, zipPath };
358
+ }
359
+
360
+ /**
361
+ * @param {string} sourceDir
362
+ * @param {string} zipPath
363
+ */
364
+ function createZip(sourceDir, zipPath) {
365
+ return new Promise((resolve, reject) => {
366
+ const output = fs.createWriteStream(zipPath);
367
+ const archive = archiver('zip', { zlib: { level: 9 } });
368
+
369
+ output.on('close', resolve);
370
+ archive.on('error', reject);
371
+
372
+ archive.pipe(output);
373
+ archive.directory(sourceDir, false);
374
+ archive.finalize();
375
+ });
376
+ }
377
+
378
+ /**
379
+ * @param {string} [cwd]
380
+ * @returns {Promise<Array<{ project: string, date: string, version: string, path: string, size: number }>>}
381
+ */
382
+ export async function listLocalArtifacts(cwd = process.cwd()) {
383
+ const artifactRoot = path.join(cwd, 'artifact');
384
+ if (!(await fs.pathExists(artifactRoot))) return [];
385
+
386
+ /** @type {Array<{ project: string, date: string, version: string, path: string, size: number }>} */
387
+ const results = [];
388
+
389
+ const projects = await fs.readdir(artifactRoot);
390
+ for (const project of projects) {
391
+ const projectDir = path.join(artifactRoot, project);
392
+ if (!(await fs.stat(projectDir)).isDirectory()) continue;
393
+
394
+ const dates = await fs.readdir(projectDir);
395
+ for (const date of dates) {
396
+ const dateDir = path.join(projectDir, date);
397
+ if (!(await fs.stat(dateDir)).isDirectory()) continue;
398
+
399
+ const versions = await fs.readdir(dateDir);
400
+ for (const version of versions) {
401
+ const versionDir = path.join(dateDir, version);
402
+ const zipPath = path.join(versionDir, 'artifact.zip');
403
+ if (await fs.pathExists(zipPath)) {
404
+ const stat = await fs.stat(zipPath);
405
+ results.push({
406
+ project,
407
+ date,
408
+ version: version.replace(/^v/, ''),
409
+ path: versionDir,
410
+ size: stat.size,
411
+ });
412
+ }
413
+ }
414
+ }
415
+ }
416
+
417
+ return results.sort((a, b) => b.date.localeCompare(a.date));
418
+ }
419
+
420
+ /**
421
+ * @param {string} versionDir
422
+ * @param {string} extractTo
423
+ */
424
+ export async function extractArtifact(versionDir, extractTo) {
425
+ const zipPath = path.join(versionDir, 'artifact.zip');
426
+ if (!(await fs.pathExists(zipPath))) {
427
+ throw new Error(`Artifact zip not found at ${zipPath}`);
428
+ }
429
+ await fs.ensureDir(extractTo);
430
+ const { execa: execaFn } = await import('execa');
431
+ if (process.platform === 'win32') {
432
+ await execaFn(
433
+ 'powershell',
434
+ ['-Command', `Expand-Archive -Path "${zipPath}" -DestinationPath "${extractTo}" -Force`],
435
+ { stdio: 'inherit' }
436
+ );
437
+ } else {
438
+ await execaFn('unzip', ['-o', zipPath, '-d', extractTo], { stdio: 'inherit' });
439
+ }
440
+ }
441
+
442
+ /**
443
+ * Rebuild artifact.zip after deploy so deployment.json is included for remote rollback.
444
+ * @param {string} artifactDir
445
+ */
446
+ export async function repackArtifactZip(artifactDir) {
447
+ const zipPath = path.join(artifactDir, 'artifact.zip');
448
+ if (!(await fs.pathExists(zipPath))) {
449
+ throw new Error(`Artifact zip not found at ${zipPath}`);
450
+ }
451
+
452
+ const tempDir = path.join(artifactDir, '_repack');
453
+ await fs.emptyDir(tempDir);
454
+ await extractArtifact(artifactDir, tempDir);
455
+
456
+ const deploymentPath = path.join(artifactDir, 'deployment.json');
457
+ if (await fs.pathExists(deploymentPath)) {
458
+ await fs.copy(deploymentPath, path.join(tempDir, 'deployment.json'));
459
+ }
460
+
461
+ for (const file of ['metadata.json', 'logs.txt', 'checksums.txt', 'release-notes.md', 'README.md']) {
462
+ const src = path.join(artifactDir, file);
463
+ if (await fs.pathExists(src)) {
464
+ await fs.copy(src, path.join(tempDir, file));
465
+ }
466
+ }
467
+
468
+ await fs.remove(zipPath);
469
+ await createZip(tempDir, zipPath);
470
+ await fs.remove(tempDir);
471
+ }
472
+
473
+ export default { createArtifact, listLocalArtifacts, extractArtifact, getArtifactDir, repackArtifactZip };
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from 'commander';
4
+ import { loadEnv } from '../core/config.js';
5
+ import { registerInitCommand } from '../commands/init.js';
6
+ import { registerBuildCommand } from '../commands/build.js';
7
+ import { registerArtifactCommand } from '../commands/artifact.js';
8
+ import { registerStorageCommand } from '../commands/storage.js';
9
+ import { registerDeployCommand } from '../commands/deploy.js';
10
+ import { registerRollbackCommand } from '../commands/rollback.js';
11
+ import { registerLogsCommand } from '../commands/logs.js';
12
+ import { registerDoctorCommand } from '../commands/doctor.js';
13
+ import { registerVerifyCommand } from '../commands/verify.js';
14
+ import { registerCleanCommand } from '../commands/clean.js';
15
+ import { registerUpdateCommand } from '../commands/update.js';
16
+ import { formatVersionOutput, printBanner, shouldShowBanner } from '../utils/author.js';
17
+
18
+ loadEnv();
19
+
20
+ const program = new Command();
21
+
22
+ program
23
+ .name('deployhub')
24
+ .description('Zero-configuration deployment and artifact manager')
25
+ .version(formatVersionOutput(), '-V, --version', 'output the version number');
26
+
27
+ program.hook('preAction', () => {
28
+ if (shouldShowBanner()) {
29
+ printBanner();
30
+ }
31
+ });
32
+
33
+ registerInitCommand(program);
34
+ registerBuildCommand(program);
35
+ registerArtifactCommand(program);
36
+ registerStorageCommand(program);
37
+ registerDeployCommand(program);
38
+ registerRollbackCommand(program);
39
+ registerLogsCommand(program);
40
+ registerDoctorCommand(program);
41
+ registerVerifyCommand(program);
42
+ registerCleanCommand(program);
43
+ registerUpdateCommand(program);
44
+
45
+ program.parse();