| | | 1 | | #!/usr/bin/env node |
| | | 2 | | import path from "node:path"; |
| | | 3 | | import type { Environment, Stack } from "aws-cdk-lib"; |
| | | 4 | | /** |
| | | 5 | | * CDK Infrastructure Deployment Entry Point |
| | | 6 | | */ |
| | | 7 | | import { App } from "aws-cdk-lib"; |
| | | 8 | | import { AppEnvironment } from "../lib/core/types"; |
| | | 9 | | import { StaticWebsiteStack } from "../lib/stacks/staticWebsiteStack"; |
| | | 10 | | |
| | | 11 | | // ============================================================================ |
| | | 12 | | // Types |
| | | 13 | | // ============================================================================ |
| | | 14 | | |
| | | 15 | | interface StaticWebsiteConfig { |
| | | 16 | | name: string; |
| | | 17 | | projectPath: string; |
| | | 18 | | subdomain?: string; |
| | | 19 | | } |
| | | 20 | | |
| | | 21 | | interface DeploymentConfig { |
| | | 22 | | repoName: string; |
| | | 23 | | branch: string; |
| | | 24 | | environment: AppEnvironment; |
| | | 25 | | domain: { |
| | | 26 | | name: string; |
| | | 27 | | certificateId: string; |
| | | 28 | | hostedZoneId: string; |
| | | 29 | | }; |
| | | 30 | | stacks: { |
| | | 31 | | frontend: { |
| | | 32 | | staticWebsites: readonly StaticWebsiteConfig[]; |
| | | 33 | | }; |
| | | 34 | | }; |
| | | 35 | | rootPath?: string; |
| | | 36 | | } |
| | | 37 | | |
| | | 38 | | // ============================================================================ |
| | | 39 | | // Configuration |
| | | 40 | | // ============================================================================ |
| | | 41 | | |
| | 1 | 42 | | const config: DeploymentConfig = { |
| | | 43 | | repoName: "envilder", |
| | | 44 | | branch: "main", |
| | | 45 | | environment: AppEnvironment.Production, |
| | | 46 | | domain: { |
| | | 47 | | name: "envilder.com", |
| | | 48 | | certificateId: "e04983fe-1561-4ebe-9166-83f77789964a", |
| | | 49 | | hostedZoneId: "Z0718467FEEOZ35UNCTO", |
| | | 50 | | }, |
| | | 51 | | stacks: { |
| | | 52 | | frontend: { |
| | | 53 | | staticWebsites: [ |
| | | 54 | | { |
| | | 55 | | name: "Website", |
| | | 56 | | projectPath: "envilder/src/website/dist", |
| | | 57 | | }, |
| | | 58 | | ], |
| | | 59 | | }, |
| | | 60 | | }, |
| | | 61 | | }; |
| | | 62 | | |
| | | 63 | | // ============================================================================ |
| | | 64 | | // Utils |
| | | 65 | | // ============================================================================ |
| | | 66 | | |
| | | 67 | | function getRootPath(rootPath?: string): string { |
| | 2 | 68 | | return rootPath ?? path.join(process.cwd(), "../../../"); |
| | | 69 | | } |
| | | 70 | | |
| | | 71 | | function resolveFullPath(rootPath: string, relativePath: string): string { |
| | 0 | 72 | | return path.join(rootPath, relativePath); |
| | | 73 | | } |
| | | 74 | | |
| | | 75 | | function logInfo(message: string): void { |
| | 1 | 76 | | process.stderr.write(`${message}\x1b[E\n`); |
| | | 77 | | } |
| | | 78 | | |
| | | 79 | | function logError(error: Error): void { |
| | 1 | 80 | | process.stderr.write(`\x1b[31m❌ Error: ${error.message}\x1b[0m\x1b[E\n`); |
| | 1 | 81 | | if (error.stack) { |
| | 1 | 82 | | process.stderr.write(`${error.stack}\x1b[E\n`); |
| | | 83 | | } |
| | | 84 | | } |
| | | 85 | | |
| | | 86 | | function logTable( |
| | | 87 | | entries: ReadonlyArray<{ label: string; value: string }>, |
| | | 88 | | ): void { |
| | 1 | 89 | | const MAX_VALUE_WIDTH = 40; |
| | 1 | 90 | | const truncate = (s: string) => |
| | 6 | 91 | | s.length > MAX_VALUE_WIDTH ? `…${s.slice(-(MAX_VALUE_WIDTH - 1))}` : s; |
| | | 92 | | |
| | 6 | 93 | | const rows = entries.map(({ label, value }) => ({ |
| | | 94 | | label, |
| | | 95 | | value: truncate(value), |
| | | 96 | | })); |
| | | 97 | | |
| | 6 | 98 | | const maxLabel = Math.max(...rows.map((e) => e.label.length)); |
| | 6 | 99 | | const maxValue = Math.max(...rows.map((e) => e.value.length)); |
| | 1 | 100 | | const header = " 📁 Deployment Info "; |
| | 1 | 101 | | const innerWidth = maxLabel + maxValue + 4; |
| | 1 | 102 | | const padding = Math.max(0, innerWidth - header.length); |
| | | 103 | | |
| | 1 | 104 | | const nl = "\x1b[E\n"; |
| | | 105 | | |
| | 1 | 106 | | process.stderr.write(nl); |
| | 1 | 107 | | process.stderr.write(`╭─${header}${"─".repeat(padding)}╮${nl}`); |
| | 1 | 108 | | for (const { label, value } of rows) { |
| | 6 | 109 | | process.stderr.write( |
| | | 110 | | `│ ${label.padEnd(maxLabel)} │ ${value.padEnd(maxValue)} │${nl}`, |
| | | 111 | | ); |
| | | 112 | | } |
| | 1 | 113 | | process.stderr.write( |
| | | 114 | | `╰${"─".repeat(maxLabel + 2)}┴${"─".repeat(maxValue + 2)}╯${nl}`, |
| | | 115 | | ); |
| | 1 | 116 | | process.stderr.write(nl); |
| | | 117 | | } |
| | | 118 | | |
| | | 119 | | export function validateConfig(config: DeploymentConfig): void { |
| | 4 | 120 | | const errors: string[] = []; |
| | | 121 | | |
| | 4 | 122 | | if (!config.repoName || config.repoName.trim() === "") { |
| | 2 | 123 | | errors.push("repoName is required and cannot be empty"); |
| | | 124 | | } |
| | | 125 | | |
| | 4 | 126 | | if (!config.branch || config.branch.trim() === "") { |
| | 1 | 127 | | errors.push("branch is required and cannot be empty"); |
| | | 128 | | } |
| | | 129 | | |
| | 4 | 130 | | if (config.environment === undefined || config.environment === null) { |
| | 0 | 131 | | errors.push("environment is required and cannot be empty"); |
| | | 132 | | } |
| | | 133 | | |
| | 4 | 134 | | if (!config.domain) { |
| | 0 | 135 | | errors.push("domain configuration is required"); |
| | | 136 | | } else { |
| | 4 | 137 | | if (!config.domain.name || config.domain.name.trim() === "") { |
| | 0 | 138 | | errors.push("domain.name is required and cannot be empty"); |
| | | 139 | | } |
| | 4 | 140 | | if ( |
| | | 141 | | !config.domain.certificateId || |
| | | 142 | | config.domain.certificateId.trim() === "" |
| | | 143 | | ) { |
| | 0 | 144 | | errors.push("domain.certificateId is required and cannot be empty"); |
| | | 145 | | } |
| | 4 | 146 | | if ( |
| | | 147 | | !config.domain.hostedZoneId || |
| | | 148 | | config.domain.hostedZoneId.trim() === "" |
| | | 149 | | ) { |
| | 0 | 150 | | errors.push("domain.hostedZoneId is required and cannot be empty"); |
| | | 151 | | } |
| | | 152 | | } |
| | | 153 | | |
| | 4 | 154 | | if (!config.stacks) { |
| | 0 | 155 | | errors.push("stacks configuration is required"); |
| | | 156 | | } else { |
| | 4 | 157 | | if (!config.stacks.frontend) { |
| | 0 | 158 | | errors.push("stacks.frontend is required"); |
| | | 159 | | } else { |
| | 4 | 160 | | const { staticWebsites } = config.stacks.frontend; |
| | 4 | 161 | | if (staticWebsites) { |
| | 4 | 162 | | for (const [index, website] of staticWebsites.entries()) { |
| | 0 | 163 | | if (!website.name || website.name.trim() === "") { |
| | 0 | 164 | | errors.push(`frontend.staticWebsites[${index}].name is required`); |
| | | 165 | | } |
| | 0 | 166 | | if (!website.projectPath || website.projectPath.trim() === "") { |
| | 0 | 167 | | errors.push( |
| | | 168 | | `frontend.staticWebsites[${index}].projectPath is required`, |
| | | 169 | | ); |
| | | 170 | | } |
| | | 171 | | } |
| | | 172 | | } |
| | | 173 | | } |
| | | 174 | | } |
| | | 175 | | |
| | 4 | 176 | | if (errors.length > 0) { |
| | 2 | 177 | | throw new Error( |
| | | 178 | | `Configuration validation failed with ${errors.length} error(s):\n${errors.join("\n")}`, |
| | | 179 | | ); |
| | | 180 | | } |
| | | 181 | | } |
| | | 182 | | |
| | | 183 | | // ============================================================================ |
| | | 184 | | // Deployment |
| | | 185 | | // ============================================================================ |
| | | 186 | | |
| | | 187 | | export function deploy(configOverride?: DeploymentConfig): Stack[] { |
| | 2 | 188 | | const effectiveConfig = configOverride ?? config; |
| | 2 | 189 | | const rootPath = getRootPath(effectiveConfig.rootPath); |
| | | 190 | | |
| | 2 | 191 | | try { |
| | 2 | 192 | | validateConfig(effectiveConfig); |
| | | 193 | | |
| | | 194 | | // Log deployment info |
| | 2 | 195 | | const entries: Array<{ label: string; value: string }> = [ |
| | | 196 | | { label: "Repository", value: effectiveConfig.repoName }, |
| | | 197 | | { label: "Branch", value: effectiveConfig.branch }, |
| | | 198 | | { label: "Environment", value: String(effectiveConfig.environment) }, |
| | | 199 | | ]; |
| | | 200 | | |
| | 2 | 201 | | if (process.env.CDK_DEFAULT_REGION) { |
| | 1 | 202 | | entries.push({ label: "Region", value: process.env.CDK_DEFAULT_REGION }); |
| | | 203 | | } |
| | 1 | 204 | | if (process.env.CDK_DEFAULT_ACCOUNT) { |
| | 1 | 205 | | entries.push({ |
| | | 206 | | label: "Account", |
| | | 207 | | value: `***${process.env.CDK_DEFAULT_ACCOUNT.slice(-4)}`, |
| | | 208 | | }); |
| | | 209 | | } |
| | | 210 | | |
| | 1 | 211 | | entries.push({ label: "Root Path", value: rootPath }); |
| | | 212 | | |
| | 1 | 213 | | for (const ws of effectiveConfig.stacks.frontend.staticWebsites) { |
| | 0 | 214 | | entries.push({ |
| | | 215 | | label: ws.name, |
| | | 216 | | value: resolveFullPath(rootPath, ws.projectPath), |
| | | 217 | | }); |
| | | 218 | | } |
| | | 219 | | |
| | 1 | 220 | | logTable(entries); |
| | | 221 | | |
| | 1 | 222 | | logInfo("🎯 Requested stacks:"); |
| | | 223 | | |
| | 1 | 224 | | const app = new App(); |
| | 1 | 225 | | const envFromCli: Environment = { |
| | | 226 | | account: process.env.CDK_DEFAULT_ACCOUNT, |
| | | 227 | | region: process.env.CDK_DEFAULT_REGION, |
| | | 228 | | }; |
| | | 229 | | |
| | 1 | 230 | | const stacks: Stack[] = []; |
| | | 231 | | |
| | 1 | 232 | | for (const websiteConfig of effectiveConfig.stacks.frontend |
| | | 233 | | .staticWebsites) { |
| | 0 | 234 | | const distFolderPath = resolveFullPath( |
| | | 235 | | rootPath, |
| | | 236 | | websiteConfig.projectPath, |
| | | 237 | | ); |
| | | 238 | | |
| | 0 | 239 | | const stack = new StaticWebsiteStack(app, { |
| | | 240 | | env: envFromCli, |
| | | 241 | | name: websiteConfig.name, |
| | | 242 | | domains: [ |
| | | 243 | | { |
| | | 244 | | subdomain: websiteConfig.subdomain, |
| | | 245 | | domainName: effectiveConfig.domain.name, |
| | | 246 | | certificateId: effectiveConfig.domain.certificateId, |
| | | 247 | | hostedZoneId: effectiveConfig.domain.hostedZoneId, |
| | | 248 | | }, |
| | | 249 | | ], |
| | | 250 | | distFolderPath, |
| | | 251 | | envName: effectiveConfig.environment, |
| | | 252 | | githubRepo: effectiveConfig.repoName, |
| | | 253 | | stackName: `${effectiveConfig.repoName}-${websiteConfig.name}`, |
| | | 254 | | }); |
| | | 255 | | |
| | 0 | 256 | | stacks.push(stack); |
| | | 257 | | } |
| | | 258 | | |
| | 1 | 259 | | return stacks; |
| | | 260 | | } catch (error) { |
| | 1 | 261 | | if (error instanceof Error) { |
| | 1 | 262 | | logError(error); |
| | | 263 | | } |
| | 1 | 264 | | throw error; |
| | | 265 | | } |
| | | 266 | | } |
| | | 267 | | |
| | 1 | 268 | | if (!process.env.VITEST) { |
| | 0 | 269 | | deploy(); |
| | | 270 | | } |